Skip to content

Change the method of get file extension. - #475

Merged
rickdijk merged 23 commits into
Baseflow:mainfrom
myafer:develop
Nov 4, 2026
Merged

rickdijk merged 23 commits into
Baseflow:mainfrom
myafer:develop

Conversation

@myafer

@myafer myafer commented Dec 4, 2024 •

Copy link
Copy Markdown
Contributor

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

Bug fix / improvement of the image resizer.

⤵️ What is the current behavior?

Whether a cached file can be resized is decided from the extension of the file. That extension comes from the Content-Type header of the response (not from the url), so:

  • Images served with a generic type like application/octet-stream are never resized.
  • The detection table is wrong in places: ico and cur are swapped, 00 00 00 00 matches any zero-prefixed binary, and tga cannot be decoded by the engine at all.
  • When a non-image file does enter the resizer, the decoding error propagates out of getImageFile and errors the stream (#400).
  • shouldResize only checks maxHeight when maxWidth is null, because the ternary parses as maxWidth != null ? width > maxWidth : (...). getImageFile(url, maxWidth: 200, maxHeight: 100) on a 150x400 image returns the 400px-tall original.

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

The image engine decides: the file is offered to ui.ImageDescriptor.encoded, which reports whether it decodes at all, its frame count and its size. A file that fails to decode is returned untouched instead of erroring the stream, and a file with more than one frame (gif, apng, animated webp) is passed through so the animation is not lost by the png re-encode. No list of signatures or extensions has to be maintained, and every format the engine supports — including platform specific ones like heif — is resizable.

Resized images are stored with a .png extension, matching the png encoding that is used. supportedFileNames is deprecated, since it no longer reflects what gets resized; removing it would be breaking, so it is kept for now.

Fixes #353 (webp not resized).

💥 Does this PR introduce a breaking change?

No. supportedFileNames is deprecated but still exported.

🐛 Recommendations for testing

flutter test covers the resize and caching behaviour, plus new tests for: an image served with a generic content type (stored as .bin) being resized and stored as .png, a url without an extension, static webp and bmp being resized, a non-image payload being returned untouched, and animated gif, apng and webp being passed through.

📝 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 main
  • Version bumped and dated CHANGELOG entry added

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

Instead of matching magic numbers, the file is offered to the image
engine: ImageDescriptor.encoded + instantiateCodec report whether the
file decodes at all, how many frames it has and how large it is. A file
that fails to decode, or that holds an animation (gif, apng, animated
webp), is returned untouched instead of erroring the stream or losing
its animation. This covers every format the engine supports, including
platform ones like heif, without a list to maintain.

Also:

- Fix shouldResize only checking maxHeight when maxWidth is null.
- Fix the comment on the stored extension: it comes from the
  Content-Type header, not from the url; storing as png was already
  correct since the resized image is png-encoded.
- Deprecate supportedFileNames, which no longer reflects what gets
  resized.

Tests: an image served with a generic content type is resized and stored
as .png, static webp and bmp are resized, a file that fails to decode is
returned untouched, and animated gif, apng and webp are passed through.
@myafer

myafer commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 02775e7 addressing the review. The detection is now engine based as suggested in the review: ImageDescriptor.encoded + instantiateCodec() replace the magic number table entirely. A file that fails to decode is returned untouched, and anything with more than one frame (gif, apng, animated webp) is passed through so the png re-encode cannot drop its animation.

Also done:

  • Fixed shouldResize ignoring maxHeight when maxWidth is set, with the || form suggested in the review.
  • Updated the PR description to the current template (main is the only branch now, and the CHANGELOG checklist item is ticked).
  • Fixed the comment on the stored .png extension and the CHANGELOG wording: the extension comes from the response's Content-Type, and there is a new test for the application/octet-stream → .bin case.
  • Marked supportedFileNames as @Deprecated.
  • New tests: static webp and bmp are resized and stored as .png, animated gif / apng / webp are returned untouched, and a non-image payload is returned untouched. flutter test passes (119 tests), flutter analyze is clean and dart format reports no changes.

The workflow run for this push is still sitting at action_required — could someone approve it so CI can run?

# Conflicts:
#	flutter_cache_manager/CHANGELOG.md
@myafer

myafer commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

Merged current main into the branch (that release also shipped 3.4.6, so this PR is bumped to 3.4.7 now, with a dated CHANGELOG entry on top of the 3.4.6 one). flutter test passes (124 tests, including the new cache-store ones from #542), flutter analyze and dart format are clean.

@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, this looks a lot better. Letting the engine decide resolves all the earlier points, and the tests cover the new cases well. I resolved those threads. There are three more things below. The first one should be fixed before this is merged.

Comment thread flutter_cache_manager/CHANGELOG.md Resolved
ImageDescriptor.width and height throw on web, so the size is taken from
the first frame instead. On IO the buffer is created from the path so the
bytes are not copied into memory for files that turn out not to be images.
The CHANGELOG wording now refers to the Content-Type header and merges the
duplicated bullets.
@myafer

myafer commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 2b58ba8 for the second round of review:

  • Web: the image size is now taken from the first frame (codec.getNextFrame() + frame.image.width/height, disposed immediately) instead of ImageDescriptor.width/height, which throws on web. Verified in Chrome: descriptor.width throws UnsupportedError, the first-frame approach gives frameCount=1 w=120 h=120, and an animated gif still reports frameCount=5.
  • Memory: on IO the buffer now comes from ui.ImmutableBuffer.fromFilePath, so the bytes are only read into memory for files whose path cannot be read (the tests use a mocked file system, that is the fallback). Web keeps fromUint8List since there is no path there. No size threshold added: a large image still has to be inspected to be resized.
  • CHANGELOG: wording now refers to the file extension derived from the Content-Type header, and the two duplicate bullets are merged.

flutter analyze clean, flutter test 124 passed on the VM. The web resize path itself is not covered by the existing test file: it loads fixtures with dart:io, so flutter test --platform chrome fails during setup before any resize case runs. I can make the fixtures in-memory (base64) so the whole file also runs on chrome, if that is wanted here.

The test file loaded its fixtures with dart:io, so `flutter test --platform
chrome` failed during setup and the web resize path was never exercised. The
images are now base64 constants in test_images.dart, which keeps the bytes in
the test bundle and works in the browser as well.

The fixtures are regenerated as plain 120x120 images (8 colour png, 1 bpp bmp,
3 frame gif/apng/webp) so they stay small: 5.7 KB in total instead of 84 KB.
@myafer

myafer commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Added fea6c33, which makes the web path actually testable instead of only reviewed by hand.

The fixtures were read with dart:io, so flutter test --platform chrome test/image_cache_manager_test.dart failed during setup (Unsupported operation: _Namespace from File.readAsBytes) and none of the resize cases ran. The images are now base64 constants in test/test_images.dart, so the bytes live in the test bundle and both platforms can run the same file. While at it the fixtures were regenerated as plain 120x120 images (8 colour png, 1 bpp bmp, 3 frame gif/apng/webp): 84 KB → 5.7 KB for the six files.

Results:

  • flutter test — 124 passed
  • flutter test --platform chrome test/image_cache_manager_test.dart — 17 of 17 passed
  • Sanity check that the suite really covers the web regression: temporarily going back to descriptor.width/descriptor.height makes it fail on chrome with Expected: <100> Actual: <120> (the image is returned untouched), so a future break of this kind won't slip through on the VM alone.

I did not touch .github/workflows/build.yaml; the quality job still runs flutter test --coverage (VM only). Adding a flutter test --platform chrome test/image_cache_manager_test.dart step would cover it in CI — happy to add it here if you want it, or leave the workflow to you.

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

Review of the current head (e077cfa).

Criterion Verdict Reason
Adds real value pass Fixes webp not being resized (#353), images served as application/octet-stream not being resized, and shouldResize ignoring maxHeight when maxWidth is set.
Really fixes it pass Detection no longer depends on the Content-Type-derived extension; the engine decides, and animated images are passed through.
Breaks something concern Behaviour changes and a deprecation ship as a patch release (see below).
Tests pass flutter test passes (128). 6 of the 8 new tests fail against main's image_cache_manager.dart and pass here; the animated gif/webp ones pass on both, as expected. The fromFilePath branch and the web path are not exercised in CI.
Contribution guidelines fail No pubspec.yaml bump, and the entry is added under the already-dated 3.4.7 (inline).

dart format --set-exit-if-changed and flutter analyze are clean.

Recommendation: request changes. The approach is right; what's left is the version/changelog and the robustness points inline (mainly the custom FileSystem bypass and errors that can still escape).

  • Semver. This deprecates supportedFileNames and changes behaviour callers can see: static gif/webp/bmp are now resized to png, resized files are stored as .png, and decode failures now return the original instead of erroring the stream, which reverses what 3.4.5's changelog promised for #400. Per pub's versioning, a deprecation plus these changes fits 3.5.0 better than a patch, with the error-handling change called out in the entry.

Comment thread flutter_cache_manager/CHANGELOG.md Resolved
…le files

Review follow-up:

- The resize now decodes straight from the probed descriptor with
  instantiateCodec(targetWidth:, targetHeight:), so the file is read and
  decoded once instead of decoding a full-size first frame and then decoding
  again through FileImage/ResizeImage. The first-frame decode remains only
  on web, where descriptor.width and height throw.
- ImmutableBuffer.fromFilePath is now only used when the file is backed by a
  LocalFileSystem, so a custom Config.fileSystem is never bypassed; other
  file systems go through file.readAsBytes().
- Files that cannot be decoded or resized return the original file with a
  warning logged through cacheLogger instead of erroring the stream, and all
  of the probing happens inside the try so read errors are handled too.
- The not-resizable outcome (not an image, animated, or within the requested
  size) is remembered per key, so repeated calls do not re-read and re-decode
  the file every time.
- Bumps the version to 3.5.0 with its own dated CHANGELOG entry, since the
  deprecation and the behaviour changes do not fit a patch release.

Verified with flutter test (146 passed) and
flutter test --platform chrome test/image_cache_manager_test.dart (17 passed).
@myafer

myafer commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

Pushed c0462d9 for this round:

  • Versioning: rebased on main (4e114a7, v3.4.8), pubspec.yaml bumped to 3.5.0, bullets moved to a dated ## [3.5.0] entry, and the error-handling change is called out there (it reverses 3.4.5's Bad state: Future already completed #400 propagation).
  • Single decode: the resize now decodes straight from the probed descriptor via instantiateCodec(targetWidth:, targetHeight:); FileImage/ResizeImage are gone. The full-size first-frame decode remains only on web, where descriptor.width/height throw.
  • Custom FileSystem respected: ImmutableBuffer.fromFilePath is only used when file.fileSystem is LocalFileSystem (covers the package's IOFileSystem); everything else, including custom Config.fileSystem implementations and web, goes through file.readAsBytes().
  • No silent failures: every decode/resize failure logs a warning through cacheLogger, and the whole probe (buffer creation included) sits inside the try, so read errors also fall back to the original file. The redundant exists() check is gone.
  • No repeated probing: the not-resizable outcome (not decodable, animated, or already within the requested size) is remembered per resized key in memory; a successful resize clears the entry.

Verification: flutter test 146 passed, flutter test --platform chrome test/image_cache_manager_test.dart 17 passed (the target-size codec path works on web), flutter analyze and dart format --set-exit-if-changed clean.

@rickdijk
rickdijk merged commit 0781a72 into Baseflow:main Nov 4, 2026
8 checks passed
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.

Webp format support for ImageCacheManager ?

3 participants