From 4e03901225fef873217913c3573cc85766e0ee67 Mon Sep 17 00:00:00 2001 From: afer <791143125@qq.com> Date: Wed, 4 Dec 2024 17:19:02 +0800 Subject: [PATCH 01/15] 1 1 --- .../cache_managers/image_cache_manager.dart | 28 ++++++++++++++++++- .../lib/src/compat/file_service_compat.dart | 16 ++++++----- flutter_cache_manager/pubspec.yaml | 2 +- 3 files changed, 37 insertions(+), 9 deletions(-) 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 614d7d9d..d0c1383a 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 @@ -61,6 +61,31 @@ mixin ImageCacheManager on BaseCacheManager { _runningResizes.remove(resizedKey); } + Future getExt(File file) async { + if (!await file.exists()) { + debugPrint('文件不存在: ${file.path}'); + return false; + } + final raf = await file.open(); + final header = await raf.read(8); + await raf.close(); + final hexString = + header.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(''); + if (hexString.startsWith('ffd8')) { + return true; // JPEG + } else if (hexString.startsWith('89504e47')) { + return true; // PNG + } else if (hexString.startsWith('00000200')) { + return true; // TGA + } else if (hexString.startsWith('00000100')) { + return true; // CUR + } else if (hexString.startsWith('00000000')) { + return true; // ICO + } + + return false; // 不支持的文件类型 + } + final Map> _runningResizes = {}; Future _resizeImageFile( @@ -69,9 +94,10 @@ mixin ImageCacheManager on BaseCacheManager { int? maxWidth, int? maxHeight, ) async { + bool isSupported = await getExt(originalFile.file); final originalFileName = originalFile.file.path; final fileExtension = originalFileName.split('.').last; - if (!supportedFileNames.contains(fileExtension)) { + if (isSupported) { return originalFile; } diff --git a/flutter_cache_manager/lib/src/compat/file_service_compat.dart b/flutter_cache_manager/lib/src/compat/file_service_compat.dart index 0df201e1..bbd7a6ff 100644 --- a/flutter_cache_manager/lib/src/compat/file_service_compat.dart +++ b/flutter_cache_manager/lib/src/compat/file_service_compat.dart @@ -63,13 +63,15 @@ class CompatFileServiceGetResponse implements FileServiceResponse { @override String get fileExtension { - var fileExtension = ''; - final contentTypeHeader = _header(HttpHeaders.contentTypeHeader); - if (contentTypeHeader != null) { - final contentType = ContentType.parse(contentTypeHeader); - fileExtension = contentType.fileExtension; - } - return fileExtension; + // var fileExtension = ''; + // final contentTypeHeader = _header(HttpHeaders.contentTypeHeader); + // if (contentTypeHeader != null) { + // final contentType = ContentType.parse(contentTypeHeader); + // fileExtension = contentType.fileExtension; + // } + // return fileExtension; + + return '.jpeg'; } @override diff --git a/flutter_cache_manager/pubspec.yaml b/flutter_cache_manager/pubspec.yaml index 4e49f688..ed849972 100644 --- a/flutter_cache_manager/pubspec.yaml +++ b/flutter_cache_manager/pubspec.yaml @@ -22,7 +22,7 @@ dependencies: uuid: ^4.4.2 dev_dependencies: - build_runner: ^2.4.12 + build_runner: ^2.4.9 flutter_lints: ^4.0.0 flutter_test: sdk: flutter From 74e71ca96a38432cf4b15fb870e2cb1d98fd4e2a Mon Sep 17 00:00:00 2001 From: afer <791143125@qq.com> Date: Wed, 4 Dec 2024 17:37:42 +0800 Subject: [PATCH 02/15] Update file_service_compat.dart --- .../lib/src/compat/file_service_compat.dart | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/flutter_cache_manager/lib/src/compat/file_service_compat.dart b/flutter_cache_manager/lib/src/compat/file_service_compat.dart index bbd7a6ff..0df201e1 100644 --- a/flutter_cache_manager/lib/src/compat/file_service_compat.dart +++ b/flutter_cache_manager/lib/src/compat/file_service_compat.dart @@ -63,15 +63,13 @@ class CompatFileServiceGetResponse implements FileServiceResponse { @override String get fileExtension { - // var fileExtension = ''; - // final contentTypeHeader = _header(HttpHeaders.contentTypeHeader); - // if (contentTypeHeader != null) { - // final contentType = ContentType.parse(contentTypeHeader); - // fileExtension = contentType.fileExtension; - // } - // return fileExtension; - - return '.jpeg'; + var fileExtension = ''; + final contentTypeHeader = _header(HttpHeaders.contentTypeHeader); + if (contentTypeHeader != null) { + final contentType = ContentType.parse(contentTypeHeader); + fileExtension = contentType.fileExtension; + } + return fileExtension; } @override From b6105f7e80d1b60253acae2106a996dfa8c6485b Mon Sep 17 00:00:00 2001 From: afer <791143125@qq.com> Date: Wed, 4 Dec 2024 17:39:30 +0800 Subject: [PATCH 03/15] Update image_cache_manager.dart --- .../lib/src/cache_managers/image_cache_manager.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 d0c1383a..7de21ac4 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 @@ -63,7 +63,7 @@ mixin ImageCacheManager on BaseCacheManager { Future getExt(File file) async { if (!await file.exists()) { - debugPrint('文件不存在: ${file.path}'); + debugPrint('file is not found: ${file.path}'); return false; } final raf = await file.open(); @@ -83,7 +83,7 @@ mixin ImageCacheManager on BaseCacheManager { return true; // ICO } - return false; // 不支持的文件类型 + return false; } final Map> _runningResizes = {}; From 60286186967dcca9668a68b1dd19100a76506822 Mon Sep 17 00:00:00 2001 From: afer <791143125@qq.com> Date: Wed, 4 Dec 2024 17:57:16 +0800 Subject: [PATCH 04/15] fix: fix method name --- .../lib/src/cache_managers/image_cache_manager.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 7de21ac4..57ba59d8 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 @@ -61,7 +61,7 @@ mixin ImageCacheManager on BaseCacheManager { _runningResizes.remove(resizedKey); } - Future getExt(File file) async { + Future _isImage(File file) async { if (!await file.exists()) { debugPrint('file is not found: ${file.path}'); return false; @@ -94,10 +94,10 @@ mixin ImageCacheManager on BaseCacheManager { int? maxWidth, int? maxHeight, ) async { - bool isSupported = await getExt(originalFile.file); + bool isImage = await _isImage(originalFile.file); final originalFileName = originalFile.file.path; final fileExtension = originalFileName.split('.').last; - if (isSupported) { + if (!isImage) { return originalFile; } From 4ec2651d1cc9cd728fa7e9880bb10757aa59c5c0 Mon Sep 17 00:00:00 2001 From: afer <791143125@qq.com> Date: Thu, 5 Dec 2024 11:36:55 +0800 Subject: [PATCH 05/15] Update build.gradle --- flutter_cache_manager/example/android/build.gradle | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flutter_cache_manager/example/android/build.gradle b/flutter_cache_manager/example/android/build.gradle index d2ffbffa..047207a5 100644 --- a/flutter_cache_manager/example/android/build.gradle +++ b/flutter_cache_manager/example/android/build.gradle @@ -16,3 +16,5 @@ subprojects { tasks.register("clean", Delete) { delete rootProject.buildDir } + +ext.kotlin_version = '1.7.10' \ No newline at end of file From b8ebe93a17b3d4212d7e58ca5326ca1100b092c1 Mon Sep 17 00:00:00 2001 From: afer <791143125@qq.com> Date: Thu, 5 Dec 2024 14:20:52 +0800 Subject: [PATCH 06/15] Update build.gradle --- flutter_cache_manager/example/android/build.gradle | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/flutter_cache_manager/example/android/build.gradle b/flutter_cache_manager/example/android/build.gradle index 047207a5..c5e4d410 100644 --- a/flutter_cache_manager/example/android/build.gradle +++ b/flutter_cache_manager/example/android/build.gradle @@ -1,3 +1,7 @@ +buildscript { + ext.kotlin_version = '1.7.10' +} + allprojects { repositories { google() @@ -17,4 +21,3 @@ tasks.register("clean", Delete) { delete rootProject.buildDir } -ext.kotlin_version = '1.7.10' \ No newline at end of file From 1d73d2eb92848b0587ada402673812d3a47cb67f Mon Sep 17 00:00:00 2001 From: afer <791143125@qq.com> Date: Thu, 5 Dec 2024 14:26:13 +0800 Subject: [PATCH 07/15] 1 --- flutter_cache_manager/example/android/build.gradle | 2 +- flutter_cache_manager/example/android/settings.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/flutter_cache_manager/example/android/build.gradle b/flutter_cache_manager/example/android/build.gradle index c5e4d410..2cda7602 100644 --- a/flutter_cache_manager/example/android/build.gradle +++ b/flutter_cache_manager/example/android/build.gradle @@ -1,5 +1,5 @@ buildscript { - ext.kotlin_version = '1.7.10' + ext.kotlin_version = '1.8.22' } allprojects { diff --git a/flutter_cache_manager/example/android/settings.gradle b/flutter_cache_manager/example/android/settings.gradle index 536165d3..8a53adcd 100644 --- a/flutter_cache_manager/example/android/settings.gradle +++ b/flutter_cache_manager/example/android/settings.gradle @@ -19,7 +19,7 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "com.android.application" version "7.3.0" apply false - id "org.jetbrains.kotlin.android" version "1.7.10" apply false + id "org.jetbrains.kotlin.android" version "1.8.22" apply false } include ":app" From 64ff206ec8ee542b0c7626fa65fac21c89f94066 Mon Sep 17 00:00:00 2001 From: afer <791143125@qq.com> Date: Thu, 5 Dec 2024 14:34:18 +0800 Subject: [PATCH 08/15] Update build.gradle --- flutter_cache_manager/example/android/build.gradle | 4 ---- 1 file changed, 4 deletions(-) diff --git a/flutter_cache_manager/example/android/build.gradle b/flutter_cache_manager/example/android/build.gradle index 2cda7602..457de897 100644 --- a/flutter_cache_manager/example/android/build.gradle +++ b/flutter_cache_manager/example/android/build.gradle @@ -1,7 +1,3 @@ -buildscript { - ext.kotlin_version = '1.8.22' -} - allprojects { repositories { google() From 1a55b01c2fcf46a0bb9dc5e47d38727ae7b34b5e Mon Sep 17 00:00:00 2001 From: afer <791143125@qq.com> Date: Thu, 5 Dec 2024 14:36:23 +0800 Subject: [PATCH 09/15] Update build.gradle --- flutter_cache_manager/example/android/build.gradle | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/flutter_cache_manager/example/android/build.gradle b/flutter_cache_manager/example/android/build.gradle index 457de897..8e9de9d8 100644 --- a/flutter_cache_manager/example/android/build.gradle +++ b/flutter_cache_manager/example/android/build.gradle @@ -15,5 +15,4 @@ subprojects { tasks.register("clean", Delete) { delete rootProject.buildDir -} - +} \ No newline at end of file From 2132fb42c07144b64684f338fc3b5c2406a0c977 Mon Sep 17 00:00:00 2001 From: afer <791143125@qq.com> Date: Mon, 19 Oct 2026 09:42:27 +0800 Subject: [PATCH 10/15] fix(image): detect resizable images by content instead of url extension The resizer decided whether a file could be resized from the extension of the file on disk. That extension is derived from the url, so images served from extension-less urls, or from urls whose extension does not match the payload, were never resized. It also treated any zero-prefixed binary as an ico. - Detect jpeg, png, ico, cur and webp by their magic number, reading 12 bytes instead of 8 (webp needs the RIFF/WEBP marker at offset 8). - Keep an extension fallback for formats without a signature at the start of the file, like tga, which only stores a signature in its footer. - Read the header in a helper that always closes the file handle. - Restore the trailing newline in example/android/build.gradle, which was dropped by an earlier commit in this branch. - Bump to 3.4.6 with a dated changelog entry, as CONTRIBUTING now requires. Tests: cover an image served without an extension and a non-image payload served with an image extension. --- flutter_cache_manager/CHANGELOG.md | 4 + .../example/android/build.gradle | 2 +- .../cache_managers/image_cache_manager.dart | 78 ++++++++++++++----- flutter_cache_manager/pubspec.yaml | 2 +- .../test/image_cache_manager_test.dart | 37 +++++++++ 5 files changed, 100 insertions(+), 23 deletions(-) diff --git a/flutter_cache_manager/CHANGELOG.md b/flutter_cache_manager/CHANGELOG.md index 9513fb80..8ac9dfb4 100644 --- a/flutter_cache_manager/CHANGELOG.md +++ b/flutter_cache_manager/CHANGELOG.md @@ -1,3 +1,7 @@ +## [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)) + ## [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/example/android/build.gradle b/flutter_cache_manager/example/android/build.gradle index 8e9de9d8..d2ffbffa 100644 --- a/flutter_cache_manager/example/android/build.gradle +++ b/flutter_cache_manager/example/android/build.gradle @@ -15,4 +15,4 @@ subprojects { tasks.register("clean", Delete) { delete rootProject.buildDir -} \ No newline at end of file +} 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 6fb17f7d..52aae85a 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,21 @@ 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. +const _imageSignatures = >[ + [0xFF, 0xD8, 0xFF], // jpeg + [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], // png + [0x00, 0x00, 0x01, 0x00], // ico + [0x00, 0x00, 0x02, 0x00], // cur +]; + +/// Extensions of the formats that have no signature at the start of the file, +/// so the only way to recognize them is by their extension. Tga for example +/// only stores a signature in its footer. +const _extensionOnlyFormats = ['tga']; + 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,30 +80,28 @@ 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. Future _isImage(File file) async { if (!await file.exists()) { - debugPrint('file is not found: ${file.path}'); return false; } - final raf = await file.open(); - final header = await raf.read(8); - await raf.close(); - final hexString = header - .map((byte) => byte.toRadixString(16).padLeft(2, '0')) - .join(''); - if (hexString.startsWith('ffd8')) { - return true; // JPEG - } else if (hexString.startsWith('89504e47')) { - return true; // PNG - } else if (hexString.startsWith('00000200')) { - return true; // TGA - } else if (hexString.startsWith('00000100')) { - return true; // CUR - } else if (hexString.startsWith('00000000')) { - return true; // ICO - } - return false; + final header = await _readHeader(file); + if (_imageSignatures.any((signature) => _startsWith(header, signature))) { + return true; + } + // WebP is a RIFF container: "RIFF" + 4 bytes of length + "WEBP". + if (header.length >= 12 && + _startsWith(header, 'RIFF'.codeUnits) && + _startsWith(header.sublist(8), 'WEBP'.codeUnits)) { + return true; + } + final extension = file.path.split('.').last.toLowerCase(); + return _extensionOnlyFormats.contains(extension); } final Map> _runningResizes = {}; @@ -99,10 +112,9 @@ mixin ImageCacheManager on BaseCacheManager { int? maxWidth, int? maxHeight, ) async { - bool isImage = await _isImage(originalFile.file); final originalFileName = originalFile.file.path; final fileExtension = originalFileName.split('.').last; - if (!isImage) { + if (!await _isImage(originalFile.file)) { return originalFile; } @@ -174,6 +186,30 @@ 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. +Future> _readHeader(File file, {int length = 12}) async { + final raf = await file.open(); + try { + return await raf.read(length); + } finally { + await raf.close(); + } +} + +/// Whether [bytes] starts with [signature]. +bool _startsWith(List bytes, List signature) { + if (bytes.length < signature.length) { + return false; + } + for (var i = 0; i < signature.length; i++) { + if (bytes[i] != signature[i]) { + return false; + } + } + return true; +} + 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..e2390548 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,42 @@ 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); + }); + }); } Future setupCacheManager() async { From 3a498ba50279af5e7137a602e125a54c986f4b7b Mon Sep 17 00:00:00 2001 From: afer <791143125@qq.com> Date: Mon, 19 Oct 2026 13:33:19 +0800 Subject: [PATCH 11/15] fix(image): also detect bmp by signature and wbmp by extension Flutter decodes bmp and wbmp as well, so they are resizable too. Bmp has a 'BM' signature and can be detected from the content. Wbmp starts with a two byte header that is too generic to match on, so it joins tga in the extension fallback. --- .../lib/src/cache_managers/image_cache_manager.dart | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 52aae85a..ff25bebd 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 @@ -13,14 +13,16 @@ const supportedFileNames = ['jpg', 'jpeg', 'png', 'tga', 'cur', 'ico']; const _imageSignatures = >[ [0xFF, 0xD8, 0xFF], // 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 signature at the start of the file, -/// so the only way to recognize them is by their extension. Tga for example -/// only stores a signature in its footer. -const _extensionOnlyFormats = ['tga']; +/// 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 From a7dac2af1dee6dd225349c09be22e3440199758e Mon Sep 17 00:00:00 2001 From: afer <791143125@qq.com> Date: Mon, 19 Oct 2026 13:42:26 +0800 Subject: [PATCH 12/15] fix(image): match jpeg on its two byte signature Two bytes (the SOI marker) are enough to tell jpeg apart from the other formats that are checked, which is what SDWebImage and most detectors do. A file that is detected as an image but fails to decode then errors out of getImageFile, as it already did for a mislabeled extension before this change. --- .../lib/src/cache_managers/image_cache_manager.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ff25bebd..470a25a9 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 @@ -11,7 +11,7 @@ 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. const _imageSignatures = >[ - [0xFF, 0xD8, 0xFF], // jpeg + [0xFF, 0xD8], // jpeg [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], // png [0x42, 0x4D], // bmp [0x00, 0x00, 0x01, 0x00], // ico From 780d6626a605549566542a1b0d6ee4d2728d8466 Mon Sep 17 00:00:00 2001 From: afer <791143125@qq.com> Date: Mon, 19 Oct 2026 13:51:12 +0800 Subject: [PATCH 13/15] fix(image): store the resized image with a png extension The extension that was passed to putFile came from the cached file path. For a url without an extension that path ends in the key itself, so the extension contained a slash and putFile failed to create the file. This is what the new test for an extension-less url caught. The resized image is always encoded as png anyway, so name it png instead of copying the extension of the url. --- flutter_cache_manager/CHANGELOG.md | 1 + .../lib/src/cache_managers/image_cache_manager.dart | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/flutter_cache_manager/CHANGELOG.md b/flutter_cache_manager/CHANGELOG.md index 8ac9dfb4..c2403c6a 100644 --- a/flutter_cache_manager/CHANGELOG.md +++ b/flutter_cache_manager/CHANGELOG.md @@ -1,6 +1,7 @@ ## [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 ## [3.4.5] - 2026-09-18 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 470a25a9..6c841913 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 @@ -114,8 +114,6 @@ mixin ImageCacheManager on BaseCacheManager { int? maxWidth, int? maxHeight, ) async { - final originalFileName = originalFile.file.path; - final fileExtension = originalFileName.split('.').last; if (!await _isImage(originalFile.file)) { return originalFile; } @@ -152,7 +150,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( From e9eca3a04bf23edff6cce52d9111f32a72b23fac Mon Sep 17 00:00:00 2001 From: afer <791143125@qq.com> Date: Mon, 19 Oct 2026 14:03:13 +0800 Subject: [PATCH 14/15] fix(image): keep an animated webp out of the resizer The resizer always re-encodes the image to png, which keeps a single frame. Now that webp is recognized by its signature, an animated webp would enter the resizer and silently lose its animation. That is the same reason gif is not recognized at all. Read the flags of the VP8X chunk, which is where an extended webp marks itself as animated, and pass those files through untouched. A static webp, with or without a VP8X chunk, is still resized. --- flutter_cache_manager/CHANGELOG.md | 1 + .../cache_managers/image_cache_manager.dart | 42 ++++++++++++++----- .../test/image_cache_manager_test.dart | 29 +++++++++++++ 3 files changed, 61 insertions(+), 11 deletions(-) diff --git a/flutter_cache_manager/CHANGELOG.md b/flutter_cache_manager/CHANGELOG.md index c2403c6a..53534429 100644 --- a/flutter_cache_manager/CHANGELOG.md +++ b/flutter_cache_manager/CHANGELOG.md @@ -2,6 +2,7 @@ * 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 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 6c841913..29c86809 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 @@ -10,6 +10,9 @@ 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. const _imageSignatures = >[ [0xFF, 0xD8], // jpeg [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], // png @@ -87,20 +90,23 @@ mixin ImageCacheManager on BaseCacheManager { /// 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) => _startsWith(header, signature))) { + if (_imageSignatures.any((signature) => _matches(header, signature))) { return true; } // WebP is a RIFF container: "RIFF" + 4 bytes of length + "WEBP". - if (header.length >= 12 && - _startsWith(header, 'RIFF'.codeUnits) && - _startsWith(header.sublist(8), 'WEBP'.codeUnits)) { - return true; + if (_matches(header, 'RIFF'.codeUnits) && + _matches(header, 'WEBP'.codeUnits, 8)) { + return !_isAnimatedWebp(header); } final extension = file.path.split('.').last.toLowerCase(); return _extensionOnlyFormats.contains(extension); @@ -191,8 +197,8 @@ 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. -Future> _readHeader(File file, {int length = 12}) async { +/// 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); @@ -201,19 +207,33 @@ Future> _readHeader(File file, {int length = 12}) async { } } -/// Whether [bytes] starts with [signature]. -bool _startsWith(List bytes, List signature) { - if (bytes.length < signature.length) { +/// 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[i] != signature[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/test/image_cache_manager_test.dart b/flutter_cache_manager/test/image_cache_manager_test.dart index e2390548..3884f292 100644 --- a/flutter_cache_manager/test/image_cache_manager_test.dart +++ b/flutter_cache_manager/test/image_cache_manager_test.dart @@ -163,6 +163,21 @@ void main() { 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); + }); }); } @@ -194,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 + ]); +} From fe77da7140f84b086d469220086e43f18ee59f63 Mon Sep 17 00:00:00 2001 From: afer <791143125@qq.com> Date: Mon, 19 Oct 2026 14:36:23 +0800 Subject: [PATCH 15/15] docs(image): explain why ico and cur overlap with tga A tga without an id field and without a colour map starts with the same four bytes as an ico or a cur, so the signatures cannot be told apart. It does not affect the result, since all of them are resizable, but it looks like a mistake without the note. --- .../lib/src/cache_managers/image_cache_manager.dart | 6 ++++++ 1 file changed, 6 insertions(+) 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 29c86809..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 @@ -13,6 +13,12 @@ const supportedFileNames = ['jpg', 'jpeg', 'png', 'tga', 'cur', 'ico']; /// /// 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