diff --git a/flutter_cache_manager/CHANGELOG.md b/flutter_cache_manager/CHANGELOG.md index 9513fb80..ef66bc4d 100644 --- a/flutter_cache_manager/CHANGELOG.md +++ b/flutter_cache_manager/CHANGELOG.md @@ -1,3 +1,7 @@ +## [3.4.6] - 2026-10-25 + +* Preserves the URL file extension when a download response omits its content type or uses `application/octet-stream` ([#489](https://github.com/Baseflow/flutter_cache_manager/issues/489)). + ## [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/compat/file_service_compat.dart b/flutter_cache_manager/lib/src/compat/file_service_compat.dart index 7d6fdbc6..e4781696 100644 --- a/flutter_cache_manager/lib/src/compat/file_service_compat.dart +++ b/flutter_cache_manager/lib/src/compat/file_service_compat.dart @@ -15,15 +15,16 @@ class FileServiceCompat extends FileService { Map? headers, }) async { final legacyResponse = await fileFetcher(url, headers: headers); - return CompatFileServiceGetResponse(legacyResponse); + return CompatFileServiceGetResponse(legacyResponse, Uri.parse(url)); } } class CompatFileServiceGetResponse implements FileServiceResponse { final FileFetcherResponse legacyResponse; + final Uri _uri; final DateTime _receivedTime = clock.now(); - CompatFileServiceGetResponse(this.legacyResponse); + CompatFileServiceGetResponse(this.legacyResponse, this._uri); String? _header(String name) { return legacyResponse.header(name); @@ -64,15 +65,10 @@ class CompatFileServiceGetResponse implements FileServiceResponse { String? get eTag => _header(HttpHeaders.etagHeader); @override - String get fileExtension { - var fileExtension = ''; - final contentTypeHeader = _header(HttpHeaders.contentTypeHeader); - if (contentTypeHeader != null) { - final contentType = ContentType.parse(contentTypeHeader); - fileExtension = contentType.fileExtension; - } - return fileExtension; - } + String get fileExtension => fileExtensionFromContentTypeOrUri( + _header(HttpHeaders.contentTypeHeader), + _uri, + ); @override int get statusCode => legacyResponse.statusCode as int; diff --git a/flutter_cache_manager/lib/src/web/file_service.dart b/flutter_cache_manager/lib/src/web/file_service.dart index 376d672e..d7cca0d4 100644 --- a/flutter_cache_manager/lib/src/web/file_service.dart +++ b/flutter_cache_manager/lib/src/web/file_service.dart @@ -38,7 +38,7 @@ class HttpFileService extends FileService { } final httpResponse = await _httpClient.send(req); - return HttpGetResponse(httpResponse); + return HttpGetResponse(httpResponse, Uri.parse(url)); } } @@ -66,11 +66,12 @@ abstract class FileServiceResponse { /// Basic implementation of a [FileServiceResponse] for http requests. class HttpGetResponse implements FileServiceResponse { - HttpGetResponse(this._response); + HttpGetResponse(this._response, this._uri); final DateTime _receivedTime = clock.now(); final http.StreamedResponse _response; + final Uri _uri; @override int get statusCode => _response.statusCode; @@ -114,13 +115,8 @@ class HttpGetResponse implements FileServiceResponse { String? get eTag => _header(HttpHeaders.etagHeader); @override - String get fileExtension { - var fileExtension = ''; - final contentTypeHeader = _header(HttpHeaders.contentTypeHeader); - if (contentTypeHeader != null) { - final contentType = ContentType.parse(contentTypeHeader); - fileExtension = contentType.fileExtension; - } - return fileExtension; - } + String get fileExtension => fileExtensionFromContentTypeOrUri( + _header(HttpHeaders.contentTypeHeader), + _uri, + ); } diff --git a/flutter_cache_manager/lib/src/web/mime_converter.dart b/flutter_cache_manager/lib/src/web/mime_converter.dart index d73e772f..cbbb06f6 100644 --- a/flutter_cache_manager/lib/src/web/mime_converter.dart +++ b/flutter_cache_manager/lib/src/web/mime_converter.dart @@ -5,6 +5,25 @@ extension ContentTypeConverter on ContentType { String get fileExtension => mimeTypes[mimeType] ?? '.$subType'; } +String fileExtensionFromContentTypeOrUri(String? contentTypeHeader, Uri uri) { + final contentType = contentTypeHeader == null + ? null + : ContentType.parse(contentTypeHeader); + + if (contentType != null && + contentType.mimeType.toLowerCase() != 'application/octet-stream') { + return contentType.fileExtension; + } + + final fileName = uri.pathSegments.isEmpty ? '' : uri.pathSegments.last; + final extensionIndex = fileName.lastIndexOf('.'); + if (extensionIndex > 0 && extensionIndex < fileName.length - 1) { + return fileName.substring(extensionIndex); + } + + return contentType?.fileExtension ?? ''; +} + /// Source of MIME Types: /// https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types /// Updated on 20th of March in 2020 while being quarantined 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/http_file_fetcher_test.dart b/flutter_cache_manager/test/http_file_fetcher_test.dart index 34f81d0b..6809b5a3 100644 --- a/flutter_cache_manager/test/http_file_fetcher_test.dart +++ b/flutter_cache_manager/test/http_file_fetcher_test.dart @@ -7,6 +7,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart'; import 'package:http/testing.dart'; +import 'helpers/mock_cache_info_repository.dart'; +import 'helpers/test_configuration.dart' show TestFileSystem; + void main() { group('Check header values', () { test('Valid headers should be parsed normally', () async { @@ -77,6 +80,103 @@ void main() { expect(response.fileExtension, '.$fileExtension'); }); + test('Download uses the URL extension for generic content type', () async { + const url = 'https://example.com/video.mp4?token=secret'; + final client = MockClient((_) async { + return Response.bytes( + Uint8List(16), + 200, + headers: {'content-type': 'application/octet-stream'}, + ); + }); + final cacheManager = CacheManager( + Config( + 'test', + fileSystem: TestFileSystem(), + repo: MockCacheInfoRepository(), + fileService: HttpFileService(httpClient: client), + ), + ); + + final result = await cacheManager.downloadFile(url); + + expect(result.file.path, endsWith('.mp4')); + }); + + test('URL extension is used when content type is missing', () async { + final client = MockClient((_) async { + return Response.bytes(Uint8List(16), 200); + }); + + final response = await HttpFileService( + httpClient: client, + ).get('https://example.com/document.pdf?download=1'); + + expect(response.fileExtension, '.pdf'); + }); + + test('Specific content type takes precedence over URL extension', () async { + final client = MockClient((_) async { + return Response.bytes( + Uint8List(16), + 200, + headers: {'content-type': 'image/jpeg'}, + ); + }); + + final response = await HttpFileService( + httpClient: client, + ).get('https://example.com/video.mp4'); + + expect(response.fileExtension, '.jpg'); + }); + + test( + 'Legacy file service uses URL extension for generic content type', + () async { + final client = MockClient((_) async { + return Response.bytes( + Uint8List(16), + 200, + headers: {'content-type': 'application/octet-stream'}, + ); + }); + + Future defaultHttpGetter( + String url, { + Map? headers, + }) async { + final httpResponse = await client.get( + Uri.parse(url), + headers: headers, + ); + return HttpFileFetcherResponse(httpResponse); + } + + final response = await FileServiceCompat( + defaultHttpGetter, + ).get('https://example.com/video.mp4'); + + expect(response.fileExtension, '.mp4'); + }, + ); + + test('Generic content type without URL extension remains .bin', () async { + final client = MockClient((_) async { + return Response.bytes( + Uint8List(16), + 200, + headers: {'content-type': 'application/octet-stream'}, + ); + }); + + final response = await HttpFileService( + httpClient: client, + ).get('https://example.com/download'); + + expect(response.fileExtension, '.bin'); + }); + test('Test CompatFileService', () async { var eTag = 'test'; var fileExtension = 'jpg';