Skip to content

Change the method of get file extension. - #475

Open
myafer wants to merge 16 commits into
Baseflow:mainfrom
myafer:develop
Open

myafer wants to merge 16 commits into
Baseflow:mainfrom
myafer:develop

Conversation

@myafer

@myafer myafer commented Dec 4, 2024 •

Copy link
Copy Markdown

✨ What kind of change does this PR introduce? (Bug fix, feature, docs update...)

Bug fix.

⤵️ What is the current behavior?

When the http response's Content-Type is not image and the _resizeimage is not work.

🆕 What is the new behavior (if this is a feature change)?

The new method is get files extension with the file's binary data.

💥 Does this PR introduce a breaking change?

No.

🐛 Recommendations for testing

📝 Links to relevant issues/docs

🤔 Checklist before submitting

  • All projects build
  • Follows style guide lines (code style guide)
  • Relevant documentation was updated
  • Rebased onto current develop

@cedvdb

cedvdb commented Apr 22, 2025 •

Copy link
Copy Markdown

@renefloor could this be reviewed ? using magic bytes is preferred over a potentially missing extension

@rickdijk
rickdijk changed the base branch from develop to main September 16, 2026 00:24
settings.gradle and pubspec.yaml conflicted on lines main has since
moved past (kotlin 2.3.20, build_runner ^2.14.0), so main's lines are
kept. image_cache_manager.dart reformatted with dart format.

Co-Authored-By: Claude <noreply@anthropic.com>
@rickdijk

Copy link
Copy Markdown
Contributor

main has moved since you opened this, and our changes to example/android/settings.gradle and pubspec.yaml left it conflicting. I have merged main into your branch and resolved those conflicts. In both files main had already moved past the versions this pull request set (Kotlin 2.3.20 rather than 1.8.22, and build_runner ^2.14.0 rather than ^2.4.9), so I kept main's lines there. Your image_cache_manager.dart change is untouched apart from one line reflowed by dart format.

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.
@myafer

myafer commented Oct 19, 2026

Copy link
Copy Markdown
Author

Pushed a follow-up on top of your merge — thanks for resolving the settings.gradle / pubspec.yaml conflicts, I left your resolution untouched. The branch is still conflict-free against main.

What changed in image_cache_manager.dart:

  • Detect jpeg / png / ico / cur by magic number, and webp by its RIFF....WEBP marker, so 12 bytes are read instead of 8.
  • Fixed the swapped ICO (00 00 01 00) / CUR (00 00 02 00) values and dropped the 00 00 00 00 check, which matched any zero-prefixed binary.
  • Kept an extension fallback, but only for formats without a header signature (tga only writes one in its footer). supportedFileNames stays as it is.
  • Removed the debugPrint, and the header is now read in a helper that closes the handle in a finally.

Tests in test/image_cache_manager_test.dart: an image served from an extension-less url is resized, and a non-image payload served with a .png url is returned untouched.

Also bumped to 3.4.6 with a dated changelog entry per CONTRIBUTING, and restored the trailing newline in example/android/build.gradle that an earlier commit in this branch dropped.

dart format is clean. I could not run flutter test locally (mockito ^5.7.0 requires Dart 3.11, I only have 3.9), so it would be great if someone could approve the workflow run — it is still sitting at action_required.

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.
@myafer

myafer commented Oct 19, 2026

Copy link
Copy Markdown
Author

Follow-up: added bmp (BM) to the signatures, and wbmp to the extension fallback.

Wbmp is in Flutter's list of decodable formats, but it cannot be detected from the content: its header is 00 00, which matches any zero-prefixed binary — the same trap as the 00 00 00 00 ico check that was removed. So like tga it is only recognized by its extension.

Gif is intentionally left out: it is decodable, but _resizeImageFile re-encodes to PNG, so resizing would turn an animated gif into a still image. Happy to add it if that trade-off is acceptable.

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.
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.
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.
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.

@rickdijk rickdijk left a comment

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.

Thanks for picking this up. Detecting the format from the content is the right direction. A few things to look at before this can land; details are inline.

Two points that don't fit on a diff line:

shouldResize ignores maxHeight when maxWidth is set (existing bug, _resizeImageFile). The ternary parses as maxWidth != null ? image.width > maxWidth : (...), so the height check only runs when maxWidth is null. getImageFile(url, maxWidth: 200, maxHeight: 100) on a 150x400 image returns the 400px-tall original. It predates this PR, but since the function is being reworked anyway it would be good to fix it here:

final shouldResize = (maxWidth != null && image.width > maxWidth) ||
    (maxHeight != null && image.height > maxHeight);

PR description. Could you update it to the current PR template? The checklist is missing the 'Version bumped and dated CHANGELOG entry added' item and still mentions develop. main is the only branch now.

const _imageSignatures = <List<int>>[
[0xFF, 0xD8], // jpeg
[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], // png
[0x42, 0x4D], // bmp

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.

The two-byte signatures (BM for bmp, FF D8 for jpeg) and 00 00 01/02 00 for ico/cur will also match files that aren't images. Such a file then goes into _decodeImage, and its error propagates out of getImageFile. Before, a file like that was returned untouched because its extension wasn't in the list. Whatever detection ends up being used, a decode failure should fall back to returning originalFile rather than erroring the stream.

/// 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

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.

An animated PNG (APNG) matches this signature too, so it gets resized to a single static frame. That goes against the doc on _isImage saying animated images are passed through. Only webp has an animation check.

/// 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'];

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.

Flutter's image codecs can't decode TGA, so a .tga file (or a TGA that matches the ico/cur signature) will make _decodeImage throw instead of being resized. I'd drop tga here. The ico/cur comment above should also stop calling TGA resizable.

/// 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 {

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.

Rather than keeping a table of magic numbers plus a webp-only animation check, could we let the engine decide? For example, ui.ImmutableBuffer.fromUint8List + ui.ImageDescriptor.encoded, or ui.instantiateImageCodec: return the original when decoding fails or frameCount > 1, and resize otherwise. That covers every format the engine supports (including platform ones like HEIF) and every animated format (gif, APNG, animated webp), with no list to maintain.

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

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.

The stored file's extension doesn't come from the url. WebHelper._setDataFromHeaders builds the path from the response's Content-Type (response.fileExtension). The real failure case is a non-image Content-Type, e.g. application/octet-stream -> .bin. Could you fix this comment and the CHANGELOG wording to match? Storing as .png is correct, since the output is png-encoded.

@@ -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'];

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.

supportedFileNames is exported publicly, but nothing uses it anymore and it no longer reflects what gets resized (no webp or bmp). Removing it would be breaking, so maybe mark it @Deprecated with a note that detection is now content-based?

});
});

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants