-
Notifications
You must be signed in to change notification settings - Fork 0
Feature/mime sniffing #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
59c88d9
6eab3dd
11b50fb
2a1d0b8
c8212d8
75ad02a
24723f8
32661c6
b046cc4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,5 @@ | ||
| export 'src/model/source_type.dart'; | ||
| export 'src/widget/any_image.dart'; | ||
| export 'src/widget/any_image.dart'; | ||
| export 'src/resolver/async_source_resolver.dart'; | ||
| export 'src/resolver/mime_resolver.dart'; | ||
| export 'src/resolver/resolver_pipeline.dart'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import '../model/resolved_source.dart'; | ||
|
|
||
| /// Contract for resolvers that require async operations | ||
| /// | ||
| /// Use this interface when resolution cannot be determined | ||
| /// from the source string alone and requires an external | ||
| /// call such as an HTTP request. | ||
| /// | ||
| /// Sync resolvers should implement [SourceResolver] instead | ||
| /// to avoid unnecessary async overhead in the pipeline | ||
| abstract interface class AsyncSourceResolver { | ||
| /// Attempts to resolve [source] into a [ResolvedSource] | ||
| /// | ||
| /// Returns null if this resolver cannot classify the source, | ||
| /// allowing the pipeline to fall through to the next resolver | ||
| Future<ResolvedSource?> resolve(String source); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| import 'package:http/http.dart' as http; | ||
| import '../model/resolved_source.dart'; | ||
| import '../model/source_type.dart'; | ||
| import 'async_source_resolver.dart'; | ||
|
|
||
| /// Resolves image format by making an HTTP HEAD request | ||
| /// and reading the Content-Type header | ||
| /// | ||
| /// Use this resolver for network URLs that do not contain | ||
| /// a file extension or other format signals | ||
| /// | ||
| /// This resolver only handles network sources. Non-network | ||
| /// sources are returned as null immediately without any | ||
| /// HTTP request | ||
| /// | ||
| /// Register it via [ResolverPipeline] as an optional async | ||
| /// resolver: | ||
| /// | ||
| /// ```dart | ||
| /// ResolverPipeline( | ||
| /// resolvers: [PrefixResolver(), ExtensionResolver()], | ||
| /// asyncResolvers: [MimeResolver()], | ||
| /// ) | ||
| /// ``` | ||
| class MimeResolver implements AsyncSourceResolver { | ||
| const MimeResolver(); | ||
|
|
||
| static const _mimeToFormat = { | ||
| 'image/svg+xml': ImageFormat.svg, | ||
| 'image/png': ImageFormat.raster, | ||
| 'image/jpeg': ImageFormat.raster, | ||
| 'image/webp': ImageFormat.raster, | ||
| 'image/gif': ImageFormat.raster, | ||
| }; | ||
|
|
||
| @override | ||
| Future<ResolvedSource?> resolve(String source) async { | ||
| // Only network sources can be resolved via MIME sniffing. | ||
| // Asset, file, and other source types are not applicable. | ||
| if (!source.startsWith('http://') && !source.startsWith('https://')) { | ||
| return null; | ||
| } | ||
|
|
||
| try { | ||
| final response = await http.head(Uri.parse(source)); | ||
| final contentType = response.headers['content-type']; | ||
|
|
||
| if (contentType == null) return null; | ||
|
|
||
| final mimeType = contentType.split(';').first.trim().toLowerCase(); | ||
| final format = _mimeToFormat[mimeType]; | ||
|
|
||
| if (format == null) return null; | ||
|
|
||
| return ResolvedSource( | ||
| raw: source, | ||
| location: ImageLocation.network, | ||
| format: format, | ||
| ); | ||
| } catch (_) { | ||
| return null; | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,23 +8,26 @@ import '../renderer/image_renderer.dart'; | |
| import '../renderer/network_raster_renderer.dart'; | ||
| import '../renderer/network_svg_renderer.dart'; | ||
| import '../resolver/extension_resolver.dart'; | ||
| import '../resolver/mime_resolver.dart'; | ||
| import '../resolver/prefix_resolver.dart'; | ||
| import '../resolver/resolver_pipeline.dart'; | ||
|
|
||
| /// A universal image widget that renders any image source | ||
| /// A universal image widget that renders any image source. | ||
| /// | ||
| /// Accepts an opaque [source] string and automatically resolves | ||
| /// the correct renderer based on location and format signals | ||
| /// the correct renderer based on location and format signals. | ||
| /// | ||
| /// Supports network URLs, asset paths, and SVG images without | ||
| /// requiring the caller to know the image type in advance | ||
| /// requiring the caller to know the image type in advance. | ||
| /// MIME sniffing is enabled by default for extension-less URLs | ||
| /// and can be disabled via [allowMimeSniff]. | ||
| /// | ||
| /// ```dart | ||
| /// AnyImage(source: 'https://example.com/image.png') | ||
| /// AnyImage(source: 'assets/icons/logo.svg') | ||
| /// ``` | ||
| class AnyImage extends StatelessWidget { | ||
| /// The image source — a URL, asset path, or file path. | ||
| class AnyImage extends StatefulWidget { | ||
| /// The image source — a URL or asset path. | ||
| final String source; | ||
|
|
||
| /// The width of the rendered image. | ||
|
|
@@ -48,9 +51,10 @@ class AnyImage extends StatelessWidget { | |
| /// signals for the resolver to determine the correct type. | ||
| final ImageFormat? format; | ||
|
|
||
| static const _pipeline = ResolverPipeline( | ||
| resolvers: [PrefixResolver(), ExtensionResolver()], | ||
| ); | ||
| /// Allows developer to opt-out of using Mime Sniffing | ||
| /// | ||
| /// Mime Sniffing is enabled by default. | ||
| final bool allowMimeSniff; | ||
|
|
||
| static const _renderers = [ | ||
| NetworkRasterRenderer(), | ||
|
|
@@ -68,36 +72,91 @@ class AnyImage extends StatelessWidget { | |
| this.placeholder, | ||
| this.errorWidget, | ||
| this.format, | ||
| this.allowMimeSniff = true, | ||
| }); | ||
|
|
||
| @override | ||
| Widget build(BuildContext context) { | ||
| var resolved = _pipeline.resolve(source); | ||
| State<AnyImage> createState() => _AnyImageState(); | ||
| } | ||
|
|
||
| class _AnyImageState extends State<AnyImage> { | ||
| late Future<ResolvedSource> _resolved; | ||
| late ResolverPipeline _pipeline; | ||
|
|
||
| @override | ||
| void initState() { | ||
| super.initState(); | ||
|
|
||
| _pipeline = _buildPipeline(); | ||
| _resolved = _resolve(); | ||
| } | ||
|
|
||
| @override | ||
| void didUpdateWidget(AnyImage oldWidget) { | ||
| super.didUpdateWidget(oldWidget); | ||
| if (oldWidget.allowMimeSniff != widget.allowMimeSniff) { | ||
| _pipeline = _buildPipeline(); | ||
| } | ||
| if (oldWidget.source != widget.source || | ||
| oldWidget.allowMimeSniff != widget.allowMimeSniff) { | ||
| _resolved = _resolve(); | ||
| } | ||
| } | ||
|
Comment on lines
+95
to
+104
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The @override
void didUpdateWidget(AnyImage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.source != widget.source ||
oldWidget.format != widget.format ||
oldWidget.pipeline != widget.pipeline) {
_resolved = _resolve();
}
} |
||
|
|
||
| ResolverPipeline _buildPipeline() { | ||
| return ResolverPipeline( | ||
| resolvers: const [PrefixResolver(), ExtensionResolver()], | ||
| asyncResolvers: widget.allowMimeSniff ? const [MimeResolver()] : const [], | ||
| ); | ||
| } | ||
|
|
||
| if (format != null) { | ||
| resolved = ResolvedSource( | ||
| raw: resolved.raw, | ||
| location: resolved.location, | ||
| format: format, | ||
| Future<ResolvedSource> _resolve() async { | ||
| if (widget.format != null) { | ||
| final sync = _pipeline.resolveSync(widget.source); | ||
| return ResolvedSource( | ||
| raw: sync.raw, | ||
| location: sync.location, | ||
| format: widget.format, | ||
| ); | ||
| } | ||
| return _pipeline.resolve(widget.source); | ||
| } | ||
|
|
||
| final renderer = _renderers.cast<ImageRenderer?>().firstWhere( | ||
| Widget _render(ResolvedSource resolved) { | ||
| final renderer = AnyImage._renderers.cast<ImageRenderer?>().firstWhere( | ||
| (r) => r!.canRender(resolved), | ||
| orElse: () => null, | ||
| ); | ||
|
|
||
| if (renderer == null) { | ||
| return errorWidget ?? const SizedBox.shrink(); | ||
| return widget.errorWidget ?? const SizedBox.shrink(); | ||
| } | ||
|
|
||
| return renderer.render( | ||
| resolved, | ||
| width: width, | ||
| height: height, | ||
| fit: fit, | ||
| placeholder: placeholder, | ||
| errorWidget: errorWidget, | ||
| width: widget.width, | ||
| height: widget.height, | ||
| fit: widget.fit, | ||
| placeholder: widget.placeholder, | ||
| errorWidget: widget.errorWidget, | ||
| ); | ||
| } | ||
|
|
||
| @override | ||
| Widget build(BuildContext context) { | ||
| return FutureBuilder<ResolvedSource>( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using To maintain the performance of the previous synchronous implementation, consider providing |
||
| future: _resolved, | ||
| builder: (context, snapshot) { | ||
| if (snapshot.hasError) { | ||
| return widget.errorWidget ?? const SizedBox.shrink(); | ||
| } | ||
|
|
||
| if (!snapshot.hasData) { | ||
| return widget.placeholder ?? const SizedBox.shrink(); | ||
| } | ||
|
|
||
| return _render(snapshot.data!); | ||
| }, | ||
| ); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The network request lacks a timeout and does not verify the HTTP status code. This can lead to the widget hanging in a loading state indefinitely if the server is unresponsive, or incorrectly resolving a source if the server returns an error page (e.g., 404) with a valid image
Content-Typeheader. It is recommended to add a timeout and check for a200 OKresponse.