Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions flutter_cache_manager/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)).
Expand Down
18 changes: 7 additions & 11 deletions flutter_cache_manager/lib/src/compat/file_service_compat.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,16 @@ class FileServiceCompat extends FileService {
Map<String, String>? 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);
Expand Down Expand Up @@ -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;
Expand Down
18 changes: 7 additions & 11 deletions flutter_cache_manager/lib/src/web/file_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ class HttpFileService extends FileService {
}
final httpResponse = await _httpClient.send(req);

return HttpGetResponse(httpResponse);
return HttpGetResponse(httpResponse, Uri.parse(url));
}
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
);
}
19 changes: 19 additions & 0 deletions flutter_cache_manager/lib/src/web/mime_converter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion flutter_cache_manager/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
100 changes: 100 additions & 0 deletions flutter_cache_manager/test/http_file_fetcher_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<FileFetcherResponse> defaultHttpGetter(
String url, {
Map<String, String>? 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';
Expand Down