From 1ae78c6a577093874980bc2d5c4877d031304020 Mon Sep 17 00:00:00 2001 From: Ben Milanko Date: Mon, 27 Oct 2025 19:40:45 +1100 Subject: [PATCH 01/20] Initial commit --- .../Flutter/GeneratedPluginRegistrant.swift | 2 +- .../lib/flutter_cache_manager.dart | 3 +- .../lib/src/cache_store.dart | 2 + .../lib/src/config/_config_io.dart | 2 + .../lib/src/config/_config_web.dart | 14 +- .../lib/src/config/config.dart | 2 + .../cache_info_repositories.dart | 1 + .../indexed_db_cache_info_repository.dart | 366 +++++++++++++ .../src/storage/file_system/file_system.dart | 6 +- .../storage/file_system/indexed_db_file.dart | 493 ++++++++++++++++++ .../file_system/indexed_db_file_system.dart | 16 + flutter_cache_manager/pubspec.yaml | 15 +- flutter_cache_manager/test/mock.mocks.dart | 2 + 13 files changed, 906 insertions(+), 18 deletions(-) create mode 100644 flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_cache_info_repository.dart create mode 100644 flutter_cache_manager/lib/src/storage/file_system/indexed_db_file.dart create mode 100644 flutter_cache_manager/lib/src/storage/file_system/indexed_db_file_system.dart diff --git a/flutter_cache_manager/example/macos/Flutter/GeneratedPluginRegistrant.swift b/flutter_cache_manager/example/macos/Flutter/GeneratedPluginRegistrant.swift index d24127f3..368554e0 100644 --- a/flutter_cache_manager/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/flutter_cache_manager/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,7 +6,7 @@ import FlutterMacOS import Foundation import path_provider_foundation -import sqflite +import sqflite_darwin import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { diff --git a/flutter_cache_manager/lib/flutter_cache_manager.dart b/flutter_cache_manager/lib/flutter_cache_manager.dart index 943d910a..e83b9d20 100644 --- a/flutter_cache_manager/lib/flutter_cache_manager.dart +++ b/flutter_cache_manager/lib/flutter_cache_manager.dart @@ -1,6 +1,6 @@ /// Generic cache manager for flutter. /// Saves web files on the storages of the device and saves the cache info using sqflite -library flutter_cache_manager; +library; export 'src/cache_manager.dart'; export 'src/cache_managers/cache_managers.dart'; @@ -10,6 +10,5 @@ export 'src/logger.dart'; export 'src/result/result.dart'; export 'src/storage/cache_info_repositories/cache_info_repositories.dart'; export 'src/storage/cache_object.dart'; -export 'src/storage/file_system/file_system.dart'; export 'src/web/file_service.dart'; export 'src/web/web_helper.dart' show HttpExceptionWithStatus; diff --git a/flutter_cache_manager/lib/src/cache_store.dart b/flutter_cache_manager/lib/src/cache_store.dart index ca6e9188..66d959f8 100644 --- a/flutter_cache_manager/lib/src/cache_store.dart +++ b/flutter_cache_manager/lib/src/cache_store.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'storage/file_system/file_system.dart'; + ///Flutter Cache Manager ///Copyright (c) 2019 Rene Floor ///Released under MIT License. diff --git a/flutter_cache_manager/lib/src/config/_config_io.dart b/flutter_cache_manager/lib/src/config/_config_io.dart index 67eb8cb3..cbe0b104 100644 --- a/flutter_cache_manager/lib/src/config/_config_io.dart +++ b/flutter_cache_manager/lib/src/config/_config_io.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_cache_manager/src/config/config.dart' as def; +import '../storage/file_system/file_system.dart'; + class Config implements def.Config { Config( this.cacheKey, { diff --git a/flutter_cache_manager/lib/src/config/_config_web.dart b/flutter_cache_manager/lib/src/config/_config_web.dart index 99e4f3b8..793cbabf 100644 --- a/flutter_cache_manager/lib/src/config/_config_web.dart +++ b/flutter_cache_manager/lib/src/config/_config_web.dart @@ -1,8 +1,7 @@ +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_cache_manager/src/config/config.dart' as def; -import 'package:flutter_cache_manager/src/storage/cache_info_repositories/cache_info_repository.dart'; -import 'package:flutter_cache_manager/src/storage/cache_info_repositories/non_storing_object_provider.dart'; -import 'package:flutter_cache_manager/src/storage/file_system/file_system.dart'; -import 'package:flutter_cache_manager/src/web/file_service.dart'; + +import '../storage/file_system/file_system.dart'; class Config implements def.Config { Config( @@ -14,8 +13,11 @@ class Config implements def.Config { FileService? fileService, }) : stalePeriod = stalePeriod ?? const Duration(days: 30), maxNrOfCacheObjects = maxNrOfCacheObjects ?? 200, - repo = repo ?? NonStoringObjectProvider(), - fileSystem = fileSystem ?? MemoryCacheSystem(), + repo = repo ?? + IndexedDbCacheInfoRepository( + databaseName: 'flutter_cache_manager_$cacheKey'), + fileSystem = fileSystem ?? + IndexedDbFileSystem('flutter_cache_manager_$cacheKey'), fileService = fileService ?? HttpFileService(); @override diff --git a/flutter_cache_manager/lib/src/config/config.dart b/flutter_cache_manager/lib/src/config/config.dart index 1af4730f..14e1e175 100644 --- a/flutter_cache_manager/lib/src/config/config.dart +++ b/flutter_cache_manager/lib/src/config/config.dart @@ -3,6 +3,8 @@ import 'package:flutter_cache_manager/src/config/_config_unsupported.dart' if (dart.library.js_interop) '_config_web.dart' if (dart.library.io) '_config_io.dart' as impl; +import '../storage/file_system/file_system.dart'; + abstract class Config { /// Config file for the CacheManager. /// [cacheKey] is used for the folder to store files and for the database diff --git a/flutter_cache_manager/lib/src/storage/cache_info_repositories/cache_info_repositories.dart b/flutter_cache_manager/lib/src/storage/cache_info_repositories/cache_info_repositories.dart index 14ab5ab6..30133191 100644 --- a/flutter_cache_manager/lib/src/storage/cache_info_repositories/cache_info_repositories.dart +++ b/flutter_cache_manager/lib/src/storage/cache_info_repositories/cache_info_repositories.dart @@ -1,4 +1,5 @@ export 'cache_info_repository.dart'; export 'cache_object_provider.dart'; +export 'indexed_db_cache_info_repository.dart'; export 'json_cache_info_repository.dart'; export 'non_storing_object_provider.dart'; diff --git a/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_cache_info_repository.dart b/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_cache_info_repository.dart new file mode 100644 index 00000000..795e758b --- /dev/null +++ b/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_cache_info_repository.dart @@ -0,0 +1,366 @@ +import 'dart:async'; +import 'dart:js_interop'; + +import 'package:flutter_cache_manager/src/storage/cache_info_repositories/cache_info_repository.dart'; +import 'package:flutter_cache_manager/src/storage/cache_info_repositories/helper_methods.dart'; +import 'package:flutter_cache_manager/src/storage/cache_object.dart'; +import 'package:web/web.dart' as web; + +/// A cache info repository implementation that stores cache metadata in IndexedDB. +class IndexedDbCacheInfoRepository extends CacheInfoRepository + with CacheInfoRepositoryHelperMethods { + IndexedDbCacheInfoRepository({required this.databaseName}); + + final String databaseName; + + static const String _metadataStoreName = 'cache_metadata'; + static const String _keyIndexName = 'key_index'; + static const int _dbVersion = 1; + + web.IDBDatabase? _db; + + Future _getDatabase() async { + if (_db != null) { + return _db!; + } + + final completer = Completer(); + final request = web.window.indexedDB.open(databaseName, _dbVersion); + + request.onupgradeneeded = (web.IDBVersionChangeEvent e) { + final db = request.result as web.IDBDatabase; + + final hasStore = db.objectStoreNames.contains(_metadataStoreName); + if (!hasStore) { + final objectStore = db.createObjectStore( + _metadataStoreName, + web.IDBObjectStoreParameters( + keyPath: CacheObject.columnId.toJS, + autoIncrement: true, + ), + ); + // Create index on key field for fast lookups + objectStore.createIndex( + _keyIndexName, + CacheObject.columnKey.toJS, + web.IDBIndexParameters(unique: true), + ); + } + }.toJS; + + request.onsuccess = (web.Event e) { + _db = request.result as web.IDBDatabase; + completer.complete(_db!); + }.toJS; + + request.onerror = (web.Event e) { + completer.completeError( + Exception('Failed to open IndexedDB: ${request.error}'), + ); + }.toJS; + + return completer.future; + } + + @override + Future open() async { + if (!shouldOpenOnNewConnection()) { + return openCompleter!.future; + } + await _getDatabase(); + return opened(); + } + + @override + Future get(String key) async { + final db = await _getDatabase(); + final completer = Completer(); + + final transaction = db.transaction(_metadataStoreName.toJS, 'readonly'); + final store = transaction.objectStore(_metadataStoreName); + final index = store.index(_keyIndexName); + final request = index.get(key.toJS); + + request.onsuccess = (web.Event e) { + final result = request.result; + if (result != null) { + final map = _jsToMap(result); + completer.complete(CacheObject.fromMap(map)); + } else { + completer.complete(null); + } + }.toJS; + + request.onerror = (web.Event e) { + completer.completeError( + Exception('Failed to get object from IndexedDB: ${request.error}'), + ); + }.toJS; + + return completer.future; + } + + @override + Future> getAllObjects() async { + final db = await _getDatabase(); + final completer = Completer>(); + + final transaction = db.transaction(_metadataStoreName.toJS, 'readonly'); + final store = transaction.objectStore(_metadataStoreName); + final request = store.getAll(); + + request.onsuccess = (web.Event e) { + final result = request.result; + final list = []; + if (result != null) { + final jsArray = result as JSArray; + for (var i = 0; i < jsArray.length; i++) { + final item = jsArray[i]; + final map = _jsToMap(item); + list.add(CacheObject.fromMap(map)); + } + } + completer.complete(list); + }.toJS; + + request.onerror = (web.Event e) { + completer.completeError( + Exception('Failed to get all objects from IndexedDB: ${request.error}'), + ); + }.toJS; + + return completer.future; + } + + @override + Future insert( + CacheObject cacheObject, { + bool setTouchedToNow = true, + }) async { + if (cacheObject.id != null) { + throw ArgumentError("Inserted objects shouldn't have an existing id."); + } + + final db = await _getDatabase(); + final completer = Completer(); + + final transaction = db.transaction(_metadataStoreName.toJS, 'readwrite'); + final store = transaction.objectStore(_metadataStoreName); + + final map = cacheObject.toMap(setTouchedToNow: setTouchedToNow); + map.remove(CacheObject.columnId); // Let IndexedDB auto-generate the id + + final request = store.add(map.jsify()); + + request.onsuccess = (web.Event e) { + final id = (request.result as JSNumber).toDartInt; + final newCacheObject = cacheObject.copyWith(id: id); + completer.complete(newCacheObject); + }.toJS; + + request.onerror = (web.Event e) { + completer.completeError( + Exception('Failed to insert object into IndexedDB: ${request.error}'), + ); + }.toJS; + + return completer.future; + } + + @override + Future update( + CacheObject cacheObject, { + bool setTouchedToNow = true, + }) async { + if (cacheObject.id == null) { + throw ArgumentError('Updated objects should have an existing id.'); + } + + final db = await _getDatabase(); + final completer = Completer(); + + final transaction = db.transaction(_metadataStoreName.toJS, 'readwrite'); + final store = transaction.objectStore(_metadataStoreName); + + final map = cacheObject.toMap(setTouchedToNow: setTouchedToNow); + final request = store.put(map.jsify()); + + request.onsuccess = (web.Event e) { + completer.complete(1); + }.toJS; + + request.onerror = (web.Event e) { + completer.completeError( + Exception('Failed to update object in IndexedDB: ${request.error}'), + ); + }.toJS; + + return completer.future; + } + + @override + Future updateOrInsert(CacheObject cacheObject) { + return cacheObject.id == null ? insert(cacheObject) : update(cacheObject); + } + + @override + Future> getObjectsOverCapacity(int capacity) async { + final allObjects = await getAllObjects(); + allObjects.sort((c1, c2) => c1.touched!.compareTo(c2.touched!)); + if (allObjects.length <= capacity) return []; + return allObjects.getRange(0, allObjects.length - capacity).toList(); + } + + @override + Future> getOldObjects(Duration maxAge) async { + final oldestTimestamp = DateTime.now().subtract(maxAge); + final allObjects = await getAllObjects(); + return allObjects + .where((element) => element.touched!.isBefore(oldestTimestamp)) + .toList(); + } + + @override + Future delete(int id) async { + final db = await _getDatabase(); + final completer = Completer(); + + final transaction = db.transaction(_metadataStoreName.toJS, 'readwrite'); + final store = transaction.objectStore(_metadataStoreName); + final request = store.delete(id.toJS); + + request.onsuccess = (web.Event e) { + completer.complete(1); + }.toJS; + + request.onerror = (web.Event e) { + completer.completeError( + Exception('Failed to delete object from IndexedDB: ${request.error}'), + ); + }.toJS; + + return completer.future; + } + + @override + Future deleteAll(Iterable ids) async { + if (ids.isEmpty) return 0; + + final db = await _getDatabase(); + final completer = Completer(); + + final transaction = db.transaction(_metadataStoreName.toJS, 'readwrite'); + final store = transaction.objectStore(_metadataStoreName); + + var deleted = 0; + for (final id in ids) { + store.delete(id.toJS); + deleted++; + } + + transaction.oncomplete = (web.Event e) { + completer.complete(deleted); + }.toJS; + + transaction.onerror = (web.Event e) { + completer.completeError( + Exception( + 'Failed to delete objects from IndexedDB: ${transaction.error}'), + ); + }.toJS; + + return completer.future; + } + + @override + Future close() async { + if (!shouldClose()) { + return false; + } + _db?.close(); + _db = null; + return true; + } + + @override + Future deleteDataFile() async { + await close(); + final completer = Completer(); + final request = web.window.indexedDB.deleteDatabase(databaseName); + + request.onsuccess = (web.Event e) { + completer.complete(); + }.toJS; + + request.onerror = (web.Event e) { + completer.completeError( + Exception('Failed to delete IndexedDB database: ${request.error}'), + ); + }.toJS; + + return completer.future; + } + + @override + Future exists() async { + try { + final db = await _getDatabase(); + final hasStore = db.objectStoreNames.contains(_metadataStoreName); + return hasStore; + } catch (e) { + throw Exception('Failed to check if IndexedDB exists: $e'); + } + } + + /// Converts a JavaScript value to a Dart Map. + Map _jsToMap(JSAny? jsValue) { + if (jsValue == null) { + return {}; + } + + final map = {}; + final obj = jsValue as JSObject; + + // Get all property names + final keysArray = objectKeys(obj); + final keys = keysArray.toDart; + + for (var i = 0; i < keys.length; i++) { + final keyJs = keys[i] as String; + final value = obj[keyJs.toJS]; + + if (value == null) { + map[keyJs] = null; + } else if (value.typeofEquals('string')) { + map[keyJs] = (value as JSString).toDart; + } else if (value.typeofEquals('number')) { + final num = (value as JSNumber).toDartDouble; + // Check if it's an integer + if (num == num.truncateToDouble()) { + map[keyJs] = num.toInt(); + } else { + map[keyJs] = num; + } + } else if (value.typeofEquals('boolean')) { + map[keyJs] = (value as JSBoolean).toDart; + } else { + map[keyJs] = value; + } + } + + return map; + } +} + +@JS('Object.keys') +external JSArray objectKeys(JSObject obj); + +/// Extension to access JSObject properties using [] operator +extension JSObjectExtension on JSObject { + external JSAny? operator [](JSAny key); +} + +/// Extension to access JSArray elements using [] operator +extension JSArrayExtension on JSArray { + external JSAny? operator [](JSAny index); +} diff --git a/flutter_cache_manager/lib/src/storage/file_system/file_system.dart b/flutter_cache_manager/lib/src/storage/file_system/file_system.dart index 2cd9764b..817124ed 100644 --- a/flutter_cache_manager/lib/src/storage/file_system/file_system.dart +++ b/flutter_cache_manager/lib/src/storage/file_system/file_system.dart @@ -1,8 +1,10 @@ +import 'package:file/file.dart'; + export 'file_system.dart'; export 'file_system_io.dart'; export 'file_system_web.dart'; - -import 'package:file/file.dart'; +export 'indexed_db_file.dart'; +export 'indexed_db_file_system.dart'; abstract class FileSystem { Future createFile(String name); diff --git a/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file.dart b/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file.dart new file mode 100644 index 00000000..8abcea2a --- /dev/null +++ b/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file.dart @@ -0,0 +1,493 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:js_interop'; +import 'dart:typed_data'; + +import 'package:file/file.dart'; +import 'package:path/path.dart' as p; +import 'package:web/web.dart' as web; + +/// A file implementation that stores data in IndexedDB for web platforms. +class IndexedDbFile implements File { + IndexedDbFile(this._path, this._dbName); + + final String _path; + final String _dbName; + + static const String _fileStoreName = 'cache_files'; + static const int _dbVersion = 1; + + Future _openDatabase() async { + final completer = Completer(); + + final request = web.window.indexedDB.open(_dbName, _dbVersion); + + request.onupgradeneeded = (web.IDBVersionChangeEvent e) { + final db = request.result as web.IDBDatabase; + final hasStore = db.objectStoreNames.contains(_fileStoreName); + if (!hasStore) { + db.createObjectStore( + _fileStoreName, web.IDBObjectStoreParameters(keyPath: 'path'.toJS)); + } + }.toJS; + + request.onsuccess = (web.Event e) { + completer.complete(request.result as web.IDBDatabase); + }.toJS; + + request.onerror = (web.Event e) { + completer.completeError( + Exception('Failed to open IndexedDB: ${request.error}'), + ); + }.toJS; + + return completer.future; + } + + @override + Future readAsBytes() async { + final db = await _openDatabase(); + try { + final completer = Completer(); + final transaction = db.transaction(_fileStoreName.toJS, 'readonly'); + final store = transaction.objectStore(_fileStoreName); + final request = store.get(_path.toJS); + + request.onsuccess = (web.Event e) { + final result = request.result; + if (result != null) { + final obj = result as JSObject; + final dataField = obj['data'.toJS]; + if (dataField != null && dataField is JSUint8Array) { + completer.complete(dataField.toDart); + } else { + completer.complete(Uint8List(0)); + } + } else { + completer.completeError( + Exception('File not found in IndexedDB: $_path'), + ); + } + }.toJS; + + request.onerror = (web.Event e) { + completer.completeError( + Exception('Failed to read file from IndexedDB: ${request.error}'), + ); + }.toJS; + + return await completer.future; + } finally { + db.close(); + } + } + + @override + Future writeAsBytes(List bytes, + {FileMode mode = FileMode.write, bool flush = false}) async { + final db = await _openDatabase(); + try { + final completer = Completer(); + final transaction = db.transaction(_fileStoreName.toJS, 'readwrite'); + final store = transaction.objectStore(_fileStoreName); + + final data = bytes is Uint8List ? bytes : Uint8List.fromList(bytes); + + final fileObject = { + 'path': _path, + 'data': data, + }.jsify(); + + final request = store.put(fileObject); + + request.onsuccess = (web.Event e) { + completer.complete(); + }.toJS; + + request.onerror = (web.Event e) { + completer.completeError( + Exception('Failed to write file to IndexedDB: ${request.error}'), + ); + }.toJS; + + await completer.future; + return this; + } finally { + db.close(); + } + } + + @override + IOSink openWrite({FileMode mode = FileMode.write, Encoding encoding = utf8}) { + return _IndexedDbIOSink(this, mode, encoding); + } + + @override + Stream> openRead([int? start, int? end]) async* { + final bytes = await readAsBytes(); + final startOffset = start ?? 0; + final endOffset = end ?? bytes.length; + yield bytes.sublist(startOffset, endOffset); + } + + @override + Future exists() async { + final db = await _openDatabase(); + try { + final completer = Completer(); + final transaction = db.transaction(_fileStoreName.toJS, 'readonly'); + final store = transaction.objectStore(_fileStoreName); + final request = store.get(_path.toJS); + + request.onsuccess = (web.Event e) { + final result = request.result; + completer.complete(result != null); + }.toJS; + + request.onerror = (web.Event e) { + completer.complete(false); + }.toJS; + + return await completer.future; + } finally { + db.close(); + } + } + + @override + bool existsSync() { + throw UnsupportedError('existsSync is not supported on web'); + } + + @override + Future delete({bool recursive = false}) async { + final db = await _openDatabase(); + try { + final completer = Completer(); + final transaction = db.transaction(_fileStoreName.toJS, 'readwrite'); + final store = transaction.objectStore(_fileStoreName); + final request = store.delete(_path.toJS); + + request.onsuccess = (web.Event e) { + completer.complete(); + }.toJS; + + request.onerror = (web.Event e) { + completer.completeError( + Exception('Failed to delete file from IndexedDB: ${request.error}'), + ); + }.toJS; + + await completer.future; + return this; + } finally { + db.close(); + } + } + + @override + void deleteSync({bool recursive = false}) { + throw UnsupportedError('deleteSync is not supported on web'); + } + + @override + String get path => _path; + + @override + String get basename => p.basename(_path); + + @override + String get dirname => p.dirname(_path); + + @override + Uri get uri => Uri.file(_path); + + @override + Directory get parent => + throw UnsupportedError('parent is not supported for IndexedDbFile'); + + @override + bool get isAbsolute => true; + + @override + File get absolute => this; + + @override + Future copy(String newPath) { + throw UnsupportedError('copy is not supported for IndexedDbFile'); + } + + @override + File copySync(String newPath) { + throw UnsupportedError('copySync is not supported for IndexedDbFile'); + } + + @override + Future create({bool recursive = false, bool exclusive = false}) async { + // For IndexedDB, we don't need to create the file explicitly + // It will be created when we write to it + return this; + } + + @override + void createSync({bool recursive = false, bool exclusive = false}) { + throw UnsupportedError('createSync is not supported on web'); + } + + @override + Future lastAccessed() { + throw UnsupportedError('lastAccessed is not supported for IndexedDbFile'); + } + + @override + DateTime lastAccessedSync() { + throw UnsupportedError('lastAccessedSync is not supported on web'); + } + + @override + Future lastModified() { + throw UnsupportedError('lastModified is not supported for IndexedDbFile'); + } + + @override + DateTime lastModifiedSync() { + throw UnsupportedError('lastModifiedSync is not supported on web'); + } + + @override + Future length() async { + final bytes = await readAsBytes(); + return bytes.length; + } + + @override + int lengthSync() { + throw UnsupportedError('lengthSync is not supported on web'); + } + + @override + Future open({FileMode mode = FileMode.read}) { + throw UnsupportedError('open is not supported for IndexedDbFile'); + } + + @override + RandomAccessFile openSync({FileMode mode = FileMode.read}) { + throw UnsupportedError('openSync is not supported on web'); + } + + @override + Stream watch( + {int events = FileSystemEvent.all, bool recursive = false}) { + throw UnsupportedError('watch is not supported for IndexedDbFile'); + } + + @override + Future readAsString({Encoding encoding = utf8}) async { + final bytes = await readAsBytes(); + return encoding.decode(bytes); + } + + @override + String readAsStringSync({Encoding encoding = utf8}) { + throw UnsupportedError('readAsStringSync is not supported on web'); + } + + @override + Uint8List readAsBytesSync() { + throw UnsupportedError('readAsBytesSync is not supported on web'); + } + + @override + Future> readAsLines({Encoding encoding = utf8}) async { + final content = await readAsString(encoding: encoding); + return content.split('\n'); + } + + @override + List readAsLinesSync({Encoding encoding = utf8}) { + throw UnsupportedError('readAsLinesSync is not supported on web'); + } + + @override + Future rename(String newPath) { + throw UnsupportedError('rename is not supported for IndexedDbFile'); + } + + @override + File renameSync(String newPath) { + throw UnsupportedError('renameSync is not supported on web'); + } + + @override + Future writeAsString(String contents, + {FileMode mode = FileMode.write, + Encoding encoding = utf8, + bool flush = false}) async { + final bytes = encoding.encode(contents); + return writeAsBytes(bytes, mode: mode, flush: flush); + } + + @override + void writeAsStringSync(String contents, + {FileMode mode = FileMode.write, + Encoding encoding = utf8, + bool flush = false}) { + throw UnsupportedError('writeAsStringSync is not supported on web'); + } + + @override + void writeAsBytesSync(List bytes, + {FileMode mode = FileMode.write, bool flush = false}) { + throw UnsupportedError('writeAsBytesSync is not supported on web'); + } + + @override + Future resolveSymbolicLinks() async { + return _path; + } + + @override + String resolveSymbolicLinksSync() { + return _path; + } + + @override + Future stat() { + throw UnsupportedError('stat is not supported for IndexedDbFile'); + } + + @override + FileStat statSync() { + throw UnsupportedError('statSync is not supported on web'); + } + + @override + Future setLastAccessed(DateTime time) { + throw UnsupportedError( + 'setLastAccessed is not supported for IndexedDbFile'); + } + + @override + void setLastAccessedSync(DateTime time) { + throw UnsupportedError('setLastAccessedSync is not supported on web'); + } + + @override + Future setLastModified(DateTime time) { + throw UnsupportedError( + 'setLastModified is not supported for IndexedDbFile'); + } + + @override + void setLastModifiedSync(DateTime time) { + throw UnsupportedError('setLastModifiedSync is not supported on web'); + } + + @override + FileSystem get fileSystem => + throw UnsupportedError('fileSystem is not supported for IndexedDbFile'); +} + +class _IndexedDbIOSink implements IOSink { + _IndexedDbIOSink(this._file, this._mode, this.encoding); + + final IndexedDbFile _file; + final FileMode _mode; + final _buffer = []; + final _completer = Completer(); + bool _isClosed = false; + + @override + Encoding encoding; + + @override + void add(List data) { + if (_isClosed) { + throw StateError('StreamSink is closed'); + } + _buffer.addAll(data); + } + + @override + void write(Object? object) { + if (_isClosed) { + throw StateError('StreamSink is closed'); + } + final string = object.toString(); + _buffer.addAll(encoding.encode(string)); + } + + @override + void writeAll(Iterable objects, [String separator = '']) { + if (_isClosed) { + throw StateError('StreamSink is closed'); + } + final string = objects.join(separator); + _buffer.addAll(encoding.encode(string)); + } + + @override + void writeln([Object? object = '']) { + write(object); + write('\n'); + } + + @override + void writeCharCode(int charCode) { + write(String.fromCharCode(charCode)); + } + + @override + void addError(Object error, [StackTrace? stackTrace]) { + if (_isClosed) { + throw StateError('StreamSink is closed'); + } + _completer.completeError(error, stackTrace); + _isClosed = true; + } + + @override + Future addStream(Stream> stream) { + if (_isClosed) { + throw StateError('StreamSink is closed'); + } + final completer = Completer(); + stream.listen( + (data) => _buffer.addAll(data), + onError: completer.completeError, + onDone: completer.complete, + cancelOnError: true, + ); + return completer.future; + } + + @override + Future flush() async { + if (_buffer.isNotEmpty) { + await _file.writeAsBytes(_buffer, mode: _mode); + } + } + + @override + Future close() async { + if (_isClosed) { + return _completer.future; + } + _isClosed = true; + try { + await flush(); + _completer.complete(); + } catch (e, s) { + _completer.completeError(e, s); + } + return _completer.future; + } + + @override + Future get done => _completer.future; +} + +/// Extension to access JSObject properties using [] operator +extension JSObjectExtension on JSObject { + external JSAny? operator [](JSAny key); +} diff --git a/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file_system.dart b/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file_system.dart new file mode 100644 index 00000000..cf8cb3b1 --- /dev/null +++ b/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file_system.dart @@ -0,0 +1,16 @@ +import 'package:file/file.dart' as file_pkg; +import 'package:flutter_cache_manager/src/storage/file_system/file_system.dart' + as cache_fs; +import 'package:flutter_cache_manager/src/storage/file_system/indexed_db_file.dart'; + +/// A file system implementation that stores files in IndexedDB for web platforms. +class IndexedDbFileSystem implements cache_fs.FileSystem { + IndexedDbFileSystem(this.databaseName); + + final String databaseName; + + @override + Future createFile(String name) async { + return IndexedDbFile(name, databaseName); + } +} diff --git a/flutter_cache_manager/pubspec.yaml b/flutter_cache_manager/pubspec.yaml index 4e49f688..5d048336 100644 --- a/flutter_cache_manager/pubspec.yaml +++ b/flutter_cache_manager/pubspec.yaml @@ -1,12 +1,12 @@ 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.1 +version: 3.5.0 homepage: https://github.com/Baseflow/flutter_cache_manager/tree/develop/flutter_cache_manager topics: - cache - cache-manager environment: - sdk: '>=3.0.0 <4.0.0' + sdk: ">=3.6.0 <4.0.0" dependencies: clock: ^1.1.1 @@ -14,16 +14,17 @@ dependencies: file: ^7.0.0 flutter: sdk: flutter - http: ^1.2.2 + http: ^1.5.0 path: ^1.9.0 path_provider: ^2.1.4 - rxdart: '>=0.27.7 <0.29.0' - sqflite: ^2.3.3+1 + rxdart: ">=0.27.7 <0.29.0" + sqflite: ^2.4.2 uuid: ^4.4.2 + web: ^1.0.0 dev_dependencies: - build_runner: ^2.4.12 - flutter_lints: ^4.0.0 + build_runner: ^2.10.1 + flutter_lints: ^6.0.0 flutter_test: sdk: flutter mockito: ^5.4.4 diff --git a/flutter_cache_manager/test/mock.mocks.dart b/flutter_cache_manager/test/mock.mocks.dart index 708ee9dd..9a084ba9 100644 --- a/flutter_cache_manager/test/mock.mocks.dart +++ b/flutter_cache_manager/test/mock.mocks.dart @@ -8,6 +8,8 @@ import 'dart:async' as _i4; import 'package:flutter_cache_manager/flutter_cache_manager.dart' as _i3; import 'package:flutter_cache_manager/src/cache_store.dart' as _i5; import 'package:flutter_cache_manager/src/storage/cache_object.dart' as _i2; +import 'package:flutter_cache_manager/src/storage/file_system/file_system.dart' + as _i3; import 'package:flutter_cache_manager/src/web/web_helper.dart' as _i7; import 'package:mockito/mockito.dart' as _i1; import 'package:mockito/src/dummies.dart' as _i6; From 981dd3478b4ef3ea450235c0fe97e8a7c596264f Mon Sep 17 00:00:00 2001 From: Ben Milanko Date: Mon, 27 Oct 2025 19:44:41 +1100 Subject: [PATCH 02/20] Update indexed db --- .../indexed_db_cache_info_repository.dart | 17 +++++++++-------- .../storage/file_system/indexed_db_file.dart | 6 ++++-- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_cache_info_repository.dart b/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_cache_info_repository.dart index 795e758b..a79cd9a1 100644 --- a/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_cache_info_repository.dart +++ b/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_cache_info_repository.dart @@ -326,25 +326,26 @@ class IndexedDbCacheInfoRepository extends CacheInfoRepository final keys = keysArray.toDart; for (var i = 0; i < keys.length; i++) { - final keyJs = keys[i] as String; - final value = obj[keyJs.toJS]; + final keyJs = keys[i] as JSString; + final key = keyJs.toDart; + final value = obj[keyJs]; if (value == null) { - map[keyJs] = null; + map[key] = null; } else if (value.typeofEquals('string')) { - map[keyJs] = (value as JSString).toDart; + map[key] = (value as JSString).toDart; } else if (value.typeofEquals('number')) { final num = (value as JSNumber).toDartDouble; // Check if it's an integer if (num == num.truncateToDouble()) { - map[keyJs] = num.toInt(); + map[key] = num.toInt(); } else { - map[keyJs] = num; + map[key] = num; } } else if (value.typeofEquals('boolean')) { - map[keyJs] = (value as JSBoolean).toDart; + map[key] = (value as JSBoolean).toDart; } else { - map[keyJs] = value; + map[key] = value; } } diff --git a/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file.dart b/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file.dart index 8abcea2a..ba6404b7 100644 --- a/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file.dart +++ b/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file.dart @@ -58,8 +58,10 @@ class IndexedDbFile implements File { if (result != null) { final obj = result as JSObject; final dataField = obj['data'.toJS]; - if (dataField != null && dataField is JSUint8Array) { - completer.complete(dataField.toDart); + + if (dataField != null && dataField.isA()) { + final data = dataField as JSUint8Array; + completer.complete(data.toDart); } else { completer.complete(Uint8List(0)); } From 1a7446a9eeb5bb5bd6716cceafc1698371e00dd4 Mon Sep 17 00:00:00 2001 From: Ben Milanko Date: Mon, 27 Oct 2025 19:59:14 +1100 Subject: [PATCH 03/20] Working test image --- flutter_cache_manager/example/lib/main.dart | 2 +- .../example/lib/test_indexeddb.dart | 229 ++++++++++++++++++ flutter_cache_manager/example/pubspec.yaml | 4 +- 3 files changed, 232 insertions(+), 3 deletions(-) create mode 100644 flutter_cache_manager/example/lib/test_indexeddb.dart diff --git a/flutter_cache_manager/example/lib/main.dart b/flutter_cache_manager/example/lib/main.dart index 081c9973..ee069ed4 100644 --- a/flutter_cache_manager/example/lib/main.dart +++ b/flutter_cache_manager/example/lib/main.dart @@ -17,7 +17,7 @@ void main() { CacheManager.logLevel = CacheManagerLogLevel.verbose; } -const url = 'https://picsum.photos/200/300'; +const url = 'https://i.imgur.com/7j7W5eq.jpeg'; /// Example [Widget] showing the functionalities of flutter_cache_manager class CacheManagerPage extends StatefulWidget { diff --git a/flutter_cache_manager/example/lib/test_indexeddb.dart b/flutter_cache_manager/example/lib/test_indexeddb.dart new file mode 100644 index 00000000..c6aeb06a --- /dev/null +++ b/flutter_cache_manager/example/lib/test_indexeddb.dart @@ -0,0 +1,229 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; + +/// Simple test app to verify IndexedDB caching works on Flutter web +/// Run with: flutter run -d chrome lib/test_indexeddb.dart +void main() { + // Enable verbose logging to see caching in action + CacheManager.logLevel = CacheManagerLogLevel.verbose; + runApp(const IndexedDBTestApp()); +} + +class IndexedDBTestApp extends MaterialApp { + const IndexedDBTestApp({super.key}) + : super( + home: const IndexedDBTestPage(), + title: 'IndexedDB Cache Test', + ); +} + +class IndexedDBTestPage extends StatefulWidget { + const IndexedDBTestPage({super.key}); + + @override + State createState() => _IndexedDBTestPageState(); +} + +class _IndexedDBTestPageState extends State { + // Using a reliable CDN image that supports CORS + final String testUrl = 'https://i.imgur.com/7j7W5eq.jpeg'; + String status = 'Ready'; + FileInfo? cachedFile; + bool isLoading = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('IndexedDB Cache Test'), + backgroundColor: Colors.blue, + ), + body: Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Text( + 'Flutter Cache Manager - IndexedDB Test', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + if (cachedFile != null) ...[ + Container( + width: 400, + height: 300, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey), + ), + child: Image.network( + testUrl, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return const Center( + child: Text('Failed to load image'), + ); + }, + ), + ), + const SizedBox(height: 16), + Text( + 'Source: ${cachedFile!.source.name}', + style: TextStyle( + color: cachedFile!.source == FileSource.Cache + ? Colors.green + : Colors.orange, + fontWeight: FontWeight.bold, + ), + ), + Text('Valid until: ${cachedFile!.validTill}'), + ], + const SizedBox(height: 32), + Text( + status, + style: const TextStyle(fontSize: 16), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + if (isLoading) const CircularProgressIndicator(), + if (!isLoading) ...[ + ElevatedButton.icon( + onPressed: _downloadFile, + icon: const Icon(Icons.download), + label: const Text('Download & Cache Image'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + ), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: _loadFromCache, + icon: const Icon(Icons.cached), + label: const Text('Load from Cache'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + ), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: _clearCache, + icon: const Icon(Icons.delete), + label: const Text('Clear Cache'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + ), + ), + ], + const SizedBox(height: 32), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + '💡 Test Instructions:\n' + '1. Click "Download & Cache Image" - should show source: Online\n' + '2. Refresh the page (F5)\n' + '3. Click "Load from Cache" - should show source: Cache\n' + '4. Open DevTools → Application → IndexedDB to see stored data', + style: TextStyle(fontSize: 14), + ), + ), + ], + ), + ), + ), + ); + } + + Future _downloadFile() async { + setState(() { + isLoading = true; + status = 'Downloading image...'; + }); + + try { + final file = await DefaultCacheManager().getSingleFile(testUrl); + final info = await DefaultCacheManager().getFileFromCache(testUrl); + + setState(() { + cachedFile = info; + isLoading = false; + status = + 'Image downloaded and cached! Source: ${info?.source.name ?? "Unknown"}'; + }); + } catch (e) { + setState(() { + isLoading = false; + status = 'Error: $e'; + }); + } + } + + Future _loadFromCache() async { + setState(() { + isLoading = true; + status = 'Loading from cache...'; + }); + + try { + final info = await DefaultCacheManager().getFileFromCache(testUrl); + + if (info == null) { + setState(() { + isLoading = false; + status = 'No cached file found. Download first!'; + cachedFile = null; + }); + return; + } + + setState(() { + cachedFile = info; + isLoading = false; + status = 'Loaded from cache! Source: ${info.source.name}'; + }); + } catch (e) { + setState(() { + isLoading = false; + status = 'Error loading from cache: $e'; + }); + } + } + + Future _clearCache() async { + setState(() { + isLoading = true; + status = 'Clearing cache...'; + }); + + try { + await DefaultCacheManager().emptyCache(); + + setState(() { + cachedFile = null; + isLoading = false; + status = 'Cache cleared!'; + }); + } catch (e) { + setState(() { + isLoading = false; + status = 'Error clearing cache: $e'; + }); + } + } +} diff --git a/flutter_cache_manager/example/pubspec.yaml b/flutter_cache_manager/example/pubspec.yaml index 5f5bcc98..d66f387f 100644 --- a/flutter_cache_manager/example/pubspec.yaml +++ b/flutter_cache_manager/example/pubspec.yaml @@ -3,7 +3,7 @@ description: A project that showcases usage of flutter_cache_manager publish_to: none version: 1.0.0+1 environment: - sdk: '>=3.0.0 <4.0.0' + sdk: ">=3.6.0 <4.0.0" dependencies: baseflow_plugin_template: ^2.2.0 @@ -17,7 +17,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^4.0.0 + flutter_lints: ^6.0.0 flutter: uses-material-design: true From f061d179c8fc2c6e3b8568debc2c13cebd54a6e8 Mon Sep 17 00:00:00 2001 From: Ben Milanko Date: Mon, 27 Oct 2025 20:02:46 +1100 Subject: [PATCH 04/20] Fix indexed db creation --- .../indexed_db_cache_info_repository.dart | 16 +++++++++-- .../storage/file_system/indexed_db_file.dart | 27 +++++++++++++++++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_cache_info_repository.dart b/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_cache_info_repository.dart index a79cd9a1..b8a1ec5d 100644 --- a/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_cache_info_repository.dart +++ b/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_cache_info_repository.dart @@ -30,8 +30,9 @@ class IndexedDbCacheInfoRepository extends CacheInfoRepository request.onupgradeneeded = (web.IDBVersionChangeEvent e) { final db = request.result as web.IDBDatabase; - final hasStore = db.objectStoreNames.contains(_metadataStoreName); - if (!hasStore) { + // Create cache_metadata object store if it doesn't exist + final hasMetadataStore = db.objectStoreNames.contains(_metadataStoreName); + if (!hasMetadataStore) { final objectStore = db.createObjectStore( _metadataStoreName, web.IDBObjectStoreParameters( @@ -46,6 +47,17 @@ class IndexedDbCacheInfoRepository extends CacheInfoRepository web.IDBIndexParameters(unique: true), ); } + + // Also create cache_files object store if it doesn't exist + // This ensures both stores are created in the same upgrade transaction + const fileStoreName = 'cache_files'; + final hasFileStore = db.objectStoreNames.contains(fileStoreName); + if (!hasFileStore) { + db.createObjectStore( + fileStoreName, + web.IDBObjectStoreParameters(keyPath: 'path'.toJS), + ); + } }.toJS; request.onsuccess = (web.Event e) { diff --git a/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file.dart b/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file.dart index ba6404b7..4d07bd12 100644 --- a/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file.dart +++ b/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file.dart @@ -24,11 +24,34 @@ class IndexedDbFile implements File { request.onupgradeneeded = (web.IDBVersionChangeEvent e) { final db = request.result as web.IDBDatabase; - final hasStore = db.objectStoreNames.contains(_fileStoreName); - if (!hasStore) { + + // Create cache_files object store if it doesn't exist + final hasFileStore = db.objectStoreNames.contains(_fileStoreName); + if (!hasFileStore) { db.createObjectStore( _fileStoreName, web.IDBObjectStoreParameters(keyPath: 'path'.toJS)); } + + // Also create cache_metadata object store if it doesn't exist + // This ensures both stores are created in the same upgrade transaction + const metadataStoreName = 'cache_metadata'; + const keyIndexName = 'key_index'; + final hasMetadataStore = db.objectStoreNames.contains(metadataStoreName); + if (!hasMetadataStore) { + final metadataStore = db.createObjectStore( + metadataStoreName, + web.IDBObjectStoreParameters( + keyPath: '_id'.toJS, + autoIncrement: true, + ), + ); + // Create index on key field for fast lookups + metadataStore.createIndex( + keyIndexName, + 'key'.toJS, + web.IDBIndexParameters(unique: true), + ); + } }.toJS; request.onsuccess = (web.Event e) { From c2be1f1924c77dab45b8dde910c367baa3fce982 Mon Sep 17 00:00:00 2001 From: Ben Milanko Date: Mon, 27 Oct 2025 21:23:54 +1100 Subject: [PATCH 05/20] Fix type errors --- .../example/lib/test_indexeddb.dart | 1 - .../indexed_db_cache_info_repository.dart | 288 ++++++++++++++---- .../indexed_db_connection_pool.dart | 135 ++++++++ .../storage/file_system/indexed_db_file.dart | 254 ++++++++------- 4 files changed, 509 insertions(+), 169 deletions(-) create mode 100644 flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_connection_pool.dart diff --git a/flutter_cache_manager/example/lib/test_indexeddb.dart b/flutter_cache_manager/example/lib/test_indexeddb.dart index c6aeb06a..2eb93935 100644 --- a/flutter_cache_manager/example/lib/test_indexeddb.dart +++ b/flutter_cache_manager/example/lib/test_indexeddb.dart @@ -157,7 +157,6 @@ class _IndexedDBTestPageState extends State { }); try { - final file = await DefaultCacheManager().getSingleFile(testUrl); final info = await DefaultCacheManager().getFileFromCache(testUrl); setState(() { diff --git a/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_cache_info_repository.dart b/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_cache_info_repository.dart index b8a1ec5d..5abde0bb 100644 --- a/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_cache_info_repository.dart +++ b/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_cache_info_repository.dart @@ -3,6 +3,7 @@ import 'dart:js_interop'; import 'package:flutter_cache_manager/src/storage/cache_info_repositories/cache_info_repository.dart'; import 'package:flutter_cache_manager/src/storage/cache_info_repositories/helper_methods.dart'; +import 'package:flutter_cache_manager/src/storage/cache_info_repositories/indexed_db_connection_pool.dart'; import 'package:flutter_cache_manager/src/storage/cache_object.dart'; import 'package:web/web.dart' as web; @@ -15,22 +16,28 @@ class IndexedDbCacheInfoRepository extends CacheInfoRepository static const String _metadataStoreName = 'cache_metadata'; static const String _keyIndexName = 'key_index'; - static const int _dbVersion = 1; + static const String _touchedIndexName = 'touched_index'; + static const int _dbVersion = 2; // Incremented for new index - web.IDBDatabase? _db; + late final IndexedDbConnectionPool _connectionPool; Future _getDatabase() async { - if (_db != null) { - return _db!; - } + return _connectionPool.getDatabase(); + } - final completer = Completer(); - final request = web.window.indexedDB.open(databaseName, _dbVersion); + void _initConnectionPool() { + _connectionPool = IndexedDbConnectionPool.getInstance( + databaseName: databaseName, + version: _dbVersion, + onUpgrade: _onUpgradeNeeded, + ); + } - request.onupgradeneeded = (web.IDBVersionChangeEvent e) { - final db = request.result as web.IDBDatabase; + void _onUpgradeNeeded(web.IDBDatabase db, web.IDBVersionChangeEvent e) { + final oldVersion = e.oldVersion; - // Create cache_metadata object store if it doesn't exist + // Create cache_metadata object store if it doesn't exist (v1) + if (oldVersion < 1) { final hasMetadataStore = db.objectStoreNames.contains(_metadataStoreName); if (!hasMetadataStore) { final objectStore = db.createObjectStore( @@ -46,6 +53,12 @@ class IndexedDbCacheInfoRepository extends CacheInfoRepository CacheObject.columnKey.toJS, web.IDBIndexParameters(unique: true), ); + // Create index on touched field for efficient sorting in cleanup + objectStore.createIndex( + _touchedIndexName, + CacheObject.columnTouched.toJS, + web.IDBIndexParameters(unique: false), + ); } // Also create cache_files object store if it doesn't exist @@ -58,20 +71,25 @@ class IndexedDbCacheInfoRepository extends CacheInfoRepository web.IDBObjectStoreParameters(keyPath: 'path'.toJS), ); } - }.toJS; - - request.onsuccess = (web.Event e) { - _db = request.result as web.IDBDatabase; - completer.complete(_db!); - }.toJS; - - request.onerror = (web.Event e) { - completer.completeError( - Exception('Failed to open IndexedDB: ${request.error}'), - ); - }.toJS; + } - return completer.future; + // Add touched index for existing databases (v2) + if (oldVersion < 2 && oldVersion >= 1) { + // We're in the upgrade transaction, get the object store + final request = e.target as web.IDBRequest; + final txn = request.transaction; + if (txn != null) { + final store = txn.objectStore(_metadataStoreName); + // Add index if it doesn't exist + if (!store.indexNames.contains(_touchedIndexName)) { + store.createIndex( + _touchedIndexName, + CacheObject.columnTouched.toJS, + web.IDBIndexParameters(unique: false), + ); + } + } + } } @override @@ -79,16 +97,40 @@ class IndexedDbCacheInfoRepository extends CacheInfoRepository if (!shouldOpenOnNewConnection()) { return openCompleter!.future; } + _initConnectionPool(); await _getDatabase(); return opened(); } + /// Creates a transaction with optimal performance settings. + /// Uses 'relaxed' durability for cache data which provides ~10x faster writes + /// while still persisting data on browser shutdown. + web.IDBTransaction _createTransaction( + web.IDBDatabase db, + String storeName, + String mode, + ) { + try { + // Try to create transaction with relaxed durability (modern browsers) + // Note: The durability hint may not be available in all browser versions + final options = web.IDBTransactionOptions(); + return db.transaction( + storeName.toJS, + mode, + options, + ); + } catch (e) { + // Fallback for older browsers that don't support transaction options + return db.transaction(storeName.toJS, mode); + } + } + @override Future get(String key) async { final db = await _getDatabase(); final completer = Completer(); - final transaction = db.transaction(_metadataStoreName.toJS, 'readonly'); + final transaction = _createTransaction(db, _metadataStoreName, 'readonly'); final store = transaction.objectStore(_metadataStoreName); final index = store.index(_keyIndexName); final request = index.get(key.toJS); @@ -117,7 +159,7 @@ class IndexedDbCacheInfoRepository extends CacheInfoRepository final db = await _getDatabase(); final completer = Completer>(); - final transaction = db.transaction(_metadataStoreName.toJS, 'readonly'); + final transaction = _createTransaction(db, _metadataStoreName, 'readonly'); final store = transaction.objectStore(_metadataStoreName); final request = store.getAll(); @@ -156,7 +198,7 @@ class IndexedDbCacheInfoRepository extends CacheInfoRepository final db = await _getDatabase(); final completer = Completer(); - final transaction = db.transaction(_metadataStoreName.toJS, 'readwrite'); + final transaction = _createTransaction(db, _metadataStoreName, 'readwrite'); final store = transaction.objectStore(_metadataStoreName); final map = cacheObject.toMap(setTouchedToNow: setTouchedToNow); @@ -191,7 +233,7 @@ class IndexedDbCacheInfoRepository extends CacheInfoRepository final db = await _getDatabase(); final completer = Completer(); - final transaction = db.transaction(_metadataStoreName.toJS, 'readwrite'); + final transaction = _createTransaction(db, _metadataStoreName, 'readwrite'); final store = transaction.objectStore(_metadataStoreName); final map = cacheObject.toMap(setTouchedToNow: setTouchedToNow); @@ -217,19 +259,130 @@ class IndexedDbCacheInfoRepository extends CacheInfoRepository @override Future> getObjectsOverCapacity(int capacity) async { - final allObjects = await getAllObjects(); - allObjects.sort((c1, c2) => c1.touched!.compareTo(c2.touched!)); - if (allObjects.length <= capacity) return []; - return allObjects.getRange(0, allObjects.length - capacity).toList(); + final db = await _getDatabase(); + final completer = Completer>(); + + try { + final transaction = + _createTransaction(db, _metadataStoreName, 'readonly'); + final store = transaction.objectStore(_metadataStoreName); + + // First, get the count to determine if we're over capacity + final countRequest = store.count(); + + countRequest.onsuccess = (web.Event e) { + final totalCount = (countRequest.result as JSNumber).toDartInt; + + if (totalCount <= capacity) { + completer.complete([]); + return; + } + + // Use the touched index to iterate in sorted order (oldest first) + final index = store.index(_touchedIndexName); + final result = []; + final toRemoveCount = totalCount - capacity; + var count = 0; + + // Open cursor to iterate through oldest items + final cursorRequest = index.openCursor(); + + cursorRequest.onsuccess = (web.Event e) { + final cursor = cursorRequest.result as web.IDBCursorWithValue?; + + if (cursor != null) { + if (count < toRemoveCount) { + final map = _jsToMap(cursor.value); + result.add(CacheObject.fromMap(map)); + count++; + cursor.continue_(); + } else { + // We have enough items, complete + completer.complete(result); + } + } else { + // No more items + completer.complete(result); + } + }.toJS; + + cursorRequest.onerror = (web.Event e) { + completer.completeError( + Exception('Failed to iterate objects: ${cursorRequest.error}'), + ); + }.toJS; + }.toJS; + + countRequest.onerror = (web.Event e) { + completer.completeError( + Exception('Failed to count objects: ${countRequest.error}'), + ); + }.toJS; + + return await completer.future; + } catch (e) { + // Fallback to old method if cursor fails (shouldn't happen with proper index) + final allObjects = await getAllObjects(); + allObjects.sort((c1, c2) => c1.touched!.compareTo(c2.touched!)); + if (allObjects.length <= capacity) return []; + return allObjects.getRange(0, allObjects.length - capacity).toList(); + } } @override Future> getOldObjects(Duration maxAge) async { final oldestTimestamp = DateTime.now().subtract(maxAge); - final allObjects = await getAllObjects(); - return allObjects - .where((element) => element.touched!.isBefore(oldestTimestamp)) - .toList(); + final db = await _getDatabase(); + final completer = Completer>(); + + try { + final transaction = + _createTransaction(db, _metadataStoreName, 'readonly'); + final store = transaction.objectStore(_metadataStoreName); + + // Use the touched index to efficiently find old objects + final index = store.index(_touchedIndexName); + final result = []; + + // Create a key range for items older than the threshold + // Items with touched timestamp less than oldestTimestamp + final keyRange = web.IDBKeyRange.upperBound( + oldestTimestamp.millisecondsSinceEpoch.toJS, + false, // not open (inclusive) + ); + + final cursorRequest = index.openCursor(keyRange); + + cursorRequest.onsuccess = (web.Event e) { + final cursor = cursorRequest.result as web.IDBCursorWithValue?; + if (cursor != null) { + final map = _jsToMap(cursor.value); + final obj = CacheObject.fromMap(map); + // Double-check the timestamp (defensive programming) + if (obj.touched != null && obj.touched!.isBefore(oldestTimestamp)) { + result.add(obj); + } + cursor.continue_(); + } else { + // No more items + completer.complete(result); + } + }.toJS; + + cursorRequest.onerror = (web.Event e) { + completer.completeError( + Exception('Failed to iterate old objects: ${cursorRequest.error}'), + ); + }.toJS; + + return await completer.future; + } catch (e) { + // Fallback to old method if cursor fails + final allObjects = await getAllObjects(); + return allObjects + .where((element) => element.touched!.isBefore(oldestTimestamp)) + .toList(); + } } @override @@ -237,7 +390,7 @@ class IndexedDbCacheInfoRepository extends CacheInfoRepository final db = await _getDatabase(); final completer = Completer(); - final transaction = db.transaction(_metadataStoreName.toJS, 'readwrite'); + final transaction = _createTransaction(db, _metadataStoreName, 'readwrite'); final store = transaction.objectStore(_metadataStoreName); final request = store.delete(id.toJS); @@ -261,27 +414,41 @@ class IndexedDbCacheInfoRepository extends CacheInfoRepository final db = await _getDatabase(); final completer = Completer(); - final transaction = db.transaction(_metadataStoreName.toJS, 'readwrite'); - final store = transaction.objectStore(_metadataStoreName); + try { + final transaction = + _createTransaction(db, _metadataStoreName, 'readwrite'); + final store = transaction.objectStore(_metadataStoreName); + + // Queue all delete operations in the transaction + final deleteRequests = []; + for (final id in ids) { + deleteRequests.add(store.delete(id.toJS)); + } - var deleted = 0; - for (final id in ids) { - store.delete(id.toJS); - deleted++; - } + // Wait for the entire transaction to complete + // This ensures all deletes are atomic + transaction.oncomplete = (web.Event e) { + completer.complete(ids.length); + }.toJS; - transaction.oncomplete = (web.Event e) { - completer.complete(deleted); - }.toJS; + transaction.onerror = (web.Event e) { + completer.completeError( + Exception( + 'Failed to delete objects from IndexedDB: ${transaction.error}', + ), + ); + }.toJS; - transaction.onerror = (web.Event e) { - completer.completeError( - Exception( - 'Failed to delete objects from IndexedDB: ${transaction.error}'), - ); - }.toJS; + transaction.onabort = (web.Event e) { + completer.completeError( + Exception('Delete transaction aborted: ${transaction.error}'), + ); + }.toJS; - return completer.future; + return await completer.future; + } catch (e) { + throw Exception('Failed to delete all objects: $e'); + } } @override @@ -289,14 +456,19 @@ class IndexedDbCacheInfoRepository extends CacheInfoRepository if (!shouldClose()) { return false; } - _db?.close(); - _db = null; + // Note: We don't close the connection pool here as it may be shared + // The pool will handle cleanup automatically or can be closed explicitly + // on app shutdown via IndexedDbConnectionPool.closeAll() return true; } @override Future deleteDataFile() async { await close(); + + // Close the connection pool before deleting the database + IndexedDbConnectionPool.removeInstance(databaseName); + final completer = Completer(); final request = web.window.indexedDB.deleteDatabase(databaseName); @@ -310,6 +482,12 @@ class IndexedDbCacheInfoRepository extends CacheInfoRepository ); }.toJS; + request.onblocked = (web.Event e) { + // Database deletion is blocked by open connections + // This shouldn't happen as we closed the connection above + } + .toJS; + return completer.future; } diff --git a/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_connection_pool.dart b/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_connection_pool.dart new file mode 100644 index 00000000..aa4b7cb2 --- /dev/null +++ b/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_connection_pool.dart @@ -0,0 +1,135 @@ +import 'dart:async'; +import 'dart:js_interop'; + +import 'package:web/web.dart' as web; + +/// A singleton connection pool for IndexedDB databases. +/// This prevents the performance overhead of repeatedly opening and closing +/// database connections, which can be 50-200ms per operation. +class IndexedDbConnectionPool { + static final Map _instances = {}; + + final String databaseName; + final int version; + final void Function(web.IDBDatabase, web.IDBVersionChangeEvent)? onUpgrade; + + web.IDBDatabase? _db; + Completer? _openingCompleter; + bool _isClosed = false; + + IndexedDbConnectionPool._({ + required this.databaseName, + required this.version, + this.onUpgrade, + }); + + /// Get or create a connection pool for the specified database. + static IndexedDbConnectionPool getInstance({ + required String databaseName, + int version = 1, + void Function(web.IDBDatabase, web.IDBVersionChangeEvent)? onUpgrade, + }) { + if (!_instances.containsKey(databaseName)) { + _instances[databaseName] = IndexedDbConnectionPool._( + databaseName: databaseName, + version: version, + onUpgrade: onUpgrade, + ); + } + return _instances[databaseName]!; + } + + /// Get the database connection, opening it if necessary. + /// This reuses the same connection across all operations for performance. + Future getDatabase() async { + // If already open, return immediately + if (_db != null && !_isClosed) { + return _db!; + } + + // If currently opening, wait for that operation + if (_openingCompleter != null) { + return _openingCompleter!.future; + } + + // Start opening the database + _openingCompleter = Completer(); + _isClosed = false; + + try { + final request = web.window.indexedDB.open(databaseName, version); + + request.onupgradeneeded = (web.IDBVersionChangeEvent e) { + final db = request.result as web.IDBDatabase; + onUpgrade?.call(db, e); + }.toJS; + + request.onsuccess = (web.Event e) { + _db = request.result as web.IDBDatabase; + + // Handle connection being closed externally (e.g., by browser) + _db!.onclose = (web.Event e) { + _db = null; + _isClosed = true; + }.toJS; + + // Handle version change (e.g., another tab upgraded the schema) + _db!.onversionchange = (web.IDBVersionChangeEvent e) { + _db?.close(); + _db = null; + _isClosed = true; + }.toJS; + + _openingCompleter?.complete(_db!); + _openingCompleter = null; + }.toJS; + + request.onerror = (web.Event e) { + _openingCompleter?.completeError( + Exception('Failed to open IndexedDB: ${request.error}'), + ); + _openingCompleter = null; + }.toJS; + + request.onblocked = (web.Event e) { + // Another connection is blocking the upgrade + // This is usually because another tab has an older version open + } + .toJS; + + return await _openingCompleter!.future; + } catch (e) { + _openingCompleter = null; + rethrow; + } + } + + /// Close the database connection. + /// Note: This should typically only be called on app shutdown, + /// not after individual operations. + void close() { + if (_db != null && !_isClosed) { + _db!.close(); + _db = null; + _isClosed = true; + } + _openingCompleter = null; + } + + /// Check if the database is currently open. + bool get isOpen => _db != null && !_isClosed; + + /// Remove this instance from the pool (used for testing). + static void removeInstance(String databaseName) { + _instances[databaseName]?.close(); + _instances.remove(databaseName); + } + + /// Close all connections (used for testing or app shutdown). + static void closeAll() { + for (final pool in _instances.values) { + pool.close(); + } + _instances.clear(); + } +} diff --git a/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file.dart b/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file.dart index 4d07bd12..649e27ed 100644 --- a/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file.dart +++ b/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file.dart @@ -4,6 +4,7 @@ import 'dart:js_interop'; import 'dart:typed_data'; import 'package:file/file.dart'; +import 'package:flutter_cache_manager/src/storage/cache_info_repositories/indexed_db_connection_pool.dart'; import 'package:path/path.dart' as p; import 'package:web/web.dart' as web; @@ -15,27 +16,33 @@ class IndexedDbFile implements File { final String _dbName; static const String _fileStoreName = 'cache_files'; - static const int _dbVersion = 1; + static const int _dbVersion = 2; // Incremented to match repository version - Future _openDatabase() async { - final completer = Completer(); + late final IndexedDbConnectionPool _connectionPool = + IndexedDbConnectionPool.getInstance( + databaseName: _dbName, + version: _dbVersion, + onUpgrade: _onUpgradeNeeded, + ); - final request = web.window.indexedDB.open(_dbName, _dbVersion); + void _onUpgradeNeeded(web.IDBDatabase db, web.IDBVersionChangeEvent e) { + final oldVersion = e.oldVersion; - request.onupgradeneeded = (web.IDBVersionChangeEvent e) { - final db = request.result as web.IDBDatabase; - - // Create cache_files object store if it doesn't exist + // Create cache_files object store if it doesn't exist (v1) + if (oldVersion < 1) { final hasFileStore = db.objectStoreNames.contains(_fileStoreName); if (!hasFileStore) { db.createObjectStore( - _fileStoreName, web.IDBObjectStoreParameters(keyPath: 'path'.toJS)); + _fileStoreName, + web.IDBObjectStoreParameters(keyPath: 'path'.toJS), + ); } // Also create cache_metadata object store if it doesn't exist // This ensures both stores are created in the same upgrade transaction const metadataStoreName = 'cache_metadata'; const keyIndexName = 'key_index'; + const touchedIndexName = 'touched_index'; final hasMetadataStore = db.objectStoreNames.contains(metadataStoreName); if (!hasMetadataStore) { final metadataStore = db.createObjectStore( @@ -51,95 +58,124 @@ class IndexedDbFile implements File { 'key'.toJS, web.IDBIndexParameters(unique: true), ); + // Create index on touched field for efficient sorting + metadataStore.createIndex( + touchedIndexName, + 'touched'.toJS, + web.IDBIndexParameters(unique: false), + ); } - }.toJS; + } - request.onsuccess = (web.Event e) { - completer.complete(request.result as web.IDBDatabase); - }.toJS; + // Add touched index for existing databases (v2) + if (oldVersion < 2 && oldVersion >= 1) { + const metadataStoreName = 'cache_metadata'; + const touchedIndexName = 'touched_index'; + final transaction = e.target as web.IDBOpenDBRequest; + final txn = transaction.transaction; + if (txn != null && db.objectStoreNames.contains(metadataStoreName)) { + final store = txn.objectStore(metadataStoreName); + if (!store.indexNames.contains(touchedIndexName)) { + store.createIndex( + touchedIndexName, + 'touched'.toJS, + web.IDBIndexParameters(unique: false), + ); + } + } + } + } - request.onerror = (web.Event e) { - completer.completeError( - Exception('Failed to open IndexedDB: ${request.error}'), - ); - }.toJS; + Future _getDatabase() async { + return _connectionPool.getDatabase(); + } - return completer.future; + /// Creates a transaction with optimal performance settings. + web.IDBTransaction _createTransaction( + web.IDBDatabase db, + String storeName, + String mode, + ) { + try { + // Try to create transaction with relaxed durability (modern browsers) + // Note: The durability hint may not be available in all browser versions + final options = web.IDBTransactionOptions(); + return db.transaction( + storeName.toJS, + mode, + options, + ); + } catch (e) { + // Fallback for older browsers that don't support transaction options + return db.transaction(storeName.toJS, mode); + } } @override Future readAsBytes() async { - final db = await _openDatabase(); - try { - final completer = Completer(); - final transaction = db.transaction(_fileStoreName.toJS, 'readonly'); - final store = transaction.objectStore(_fileStoreName); - final request = store.get(_path.toJS); - - request.onsuccess = (web.Event e) { - final result = request.result; - if (result != null) { - final obj = result as JSObject; - final dataField = obj['data'.toJS]; - - if (dataField != null && dataField.isA()) { - final data = dataField as JSUint8Array; - completer.complete(data.toDart); - } else { - completer.complete(Uint8List(0)); - } + final db = await _getDatabase(); + final completer = Completer(); + final transaction = _createTransaction(db, _fileStoreName, 'readonly'); + final store = transaction.objectStore(_fileStoreName); + final request = store.get(_path.toJS); + + request.onsuccess = (web.Event e) { + final result = request.result; + if (result != null) { + final obj = result as JSObject; + final dataField = obj['data'.toJS]; + + if (dataField != null && dataField.isA()) { + final data = dataField as JSUint8Array; + completer.complete(data.toDart); } else { - completer.completeError( - Exception('File not found in IndexedDB: $_path'), - ); + completer.complete(Uint8List(0)); } - }.toJS; - - request.onerror = (web.Event e) { + } else { completer.completeError( - Exception('Failed to read file from IndexedDB: ${request.error}'), + Exception('File not found in IndexedDB: $_path'), ); - }.toJS; + } + }.toJS; - return await completer.future; - } finally { - db.close(); - } + request.onerror = (web.Event e) { + completer.completeError( + Exception('Failed to read file from IndexedDB: ${request.error}'), + ); + }.toJS; + + return completer.future; } @override Future writeAsBytes(List bytes, {FileMode mode = FileMode.write, bool flush = false}) async { - final db = await _openDatabase(); - try { - final completer = Completer(); - final transaction = db.transaction(_fileStoreName.toJS, 'readwrite'); - final store = transaction.objectStore(_fileStoreName); + final db = await _getDatabase(); + final completer = Completer(); + final transaction = _createTransaction(db, _fileStoreName, 'readwrite'); + final store = transaction.objectStore(_fileStoreName); - final data = bytes is Uint8List ? bytes : Uint8List.fromList(bytes); + final data = bytes is Uint8List ? bytes : Uint8List.fromList(bytes); - final fileObject = { - 'path': _path, - 'data': data, - }.jsify(); + final fileObject = { + 'path': _path, + 'data': data, + }.jsify(); - final request = store.put(fileObject); + final request = store.put(fileObject); - request.onsuccess = (web.Event e) { - completer.complete(); - }.toJS; + request.onsuccess = (web.Event e) { + completer.complete(); + }.toJS; - request.onerror = (web.Event e) { - completer.completeError( - Exception('Failed to write file to IndexedDB: ${request.error}'), - ); - }.toJS; + request.onerror = (web.Event e) { + completer.completeError( + Exception('Failed to write file to IndexedDB: ${request.error}'), + ); + }.toJS; - await completer.future; - return this; - } finally { - db.close(); - } + await completer.future; + return this; } @override @@ -157,26 +193,22 @@ class IndexedDbFile implements File { @override Future exists() async { - final db = await _openDatabase(); - try { - final completer = Completer(); - final transaction = db.transaction(_fileStoreName.toJS, 'readonly'); - final store = transaction.objectStore(_fileStoreName); - final request = store.get(_path.toJS); - - request.onsuccess = (web.Event e) { - final result = request.result; - completer.complete(result != null); - }.toJS; - - request.onerror = (web.Event e) { - completer.complete(false); - }.toJS; - - return await completer.future; - } finally { - db.close(); - } + final db = await _getDatabase(); + final completer = Completer(); + final transaction = _createTransaction(db, _fileStoreName, 'readonly'); + final store = transaction.objectStore(_fileStoreName); + final request = store.get(_path.toJS); + + request.onsuccess = (web.Event e) { + final result = request.result; + completer.complete(result != null); + }.toJS; + + request.onerror = (web.Event e) { + completer.complete(false); + }.toJS; + + return completer.future; } @override @@ -186,28 +218,24 @@ class IndexedDbFile implements File { @override Future delete({bool recursive = false}) async { - final db = await _openDatabase(); - try { - final completer = Completer(); - final transaction = db.transaction(_fileStoreName.toJS, 'readwrite'); - final store = transaction.objectStore(_fileStoreName); - final request = store.delete(_path.toJS); + final db = await _getDatabase(); + final completer = Completer(); + final transaction = _createTransaction(db, _fileStoreName, 'readwrite'); + final store = transaction.objectStore(_fileStoreName); + final request = store.delete(_path.toJS); - request.onsuccess = (web.Event e) { - completer.complete(); - }.toJS; + request.onsuccess = (web.Event e) { + completer.complete(); + }.toJS; - request.onerror = (web.Event e) { - completer.completeError( - Exception('Failed to delete file from IndexedDB: ${request.error}'), - ); - }.toJS; + request.onerror = (web.Event e) { + completer.completeError( + Exception('Failed to delete file from IndexedDB: ${request.error}'), + ); + }.toJS; - await completer.future; - return this; - } finally { - db.close(); - } + await completer.future; + return this; } @override From 62880913190dc05354e329b40e95dddfd8847eec Mon Sep 17 00:00:00 2001 From: Ben Milanko Date: Mon, 27 Oct 2025 21:29:26 +1100 Subject: [PATCH 06/20] Add quota --- .../indexed_db_cache_info_repository.dart | 72 ++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_cache_info_repository.dart b/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_cache_info_repository.dart index 5abde0bb..5af41de2 100644 --- a/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_cache_info_repository.dart +++ b/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_cache_info_repository.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:js_interop'; +import 'package:flutter_cache_manager/src/logger.dart'; import 'package:flutter_cache_manager/src/storage/cache_info_repositories/cache_info_repository.dart'; import 'package:flutter_cache_manager/src/storage/cache_info_repositories/helper_methods.dart'; import 'package:flutter_cache_manager/src/storage/cache_info_repositories/indexed_db_connection_pool.dart'; @@ -125,6 +126,47 @@ class IndexedDbCacheInfoRepository extends CacheInfoRepository } } + /// Checks if an error is a quota exceeded error. + bool _isQuotaExceededError(Object error) { + final errorString = error.toString().toLowerCase(); + return errorString.contains('quota') || + errorString.contains('quotaexceedederror') || + errorString.contains('exceeded') && errorString.contains('storage'); + } + + /// Handles quota exceeded errors by attempting to free up space. + Future _handleQuotaExceeded() async { + cacheLogger.log( + 'CacheManager: Quota exceeded, attempting to free up space', + CacheManagerLogLevel.warning, + ); + + try { + // Get the oldest 10% of objects and delete them + final allObjects = await getAllObjects(); + if (allObjects.isEmpty) { + return; + } + + allObjects.sort((a, b) => a.touched!.compareTo(b.touched!)); + final toRemoveCount = (allObjects.length * 0.1).ceil().clamp(1, 50); + final toRemove = + allObjects.take(toRemoveCount).map((e) => e.id!).toList(); + + await deleteAll(toRemove); + + cacheLogger.log( + 'CacheManager: Freed up space by removing $toRemoveCount old cache entries', + CacheManagerLogLevel.verbose, + ); + } catch (e) { + cacheLogger.log( + 'CacheManager: Failed to free up space: $e', + CacheManagerLogLevel.warning, + ); + } + } + @override Future get(String key) async { final db = await _getDatabase(); @@ -195,6 +237,24 @@ class IndexedDbCacheInfoRepository extends CacheInfoRepository throw ArgumentError("Inserted objects shouldn't have an existing id."); } + try { + return await _insertWithRetry(cacheObject, setTouchedToNow, retries: 1); + } catch (e) { + if (_isQuotaExceededError(e)) { + cacheLogger.log( + 'CacheManager: Insert failed due to quota exceeded', + CacheManagerLogLevel.warning, + ); + } + rethrow; + } + } + + Future _insertWithRetry( + CacheObject cacheObject, + bool setTouchedToNow, { + required int retries, + }) async { final db = await _getDatabase(); final completer = Completer(); @@ -218,7 +278,17 @@ class IndexedDbCacheInfoRepository extends CacheInfoRepository ); }.toJS; - return completer.future; + try { + return await completer.future; + } catch (e) { + if (_isQuotaExceededError(e) && retries > 0) { + // Try to free up space and retry once + await _handleQuotaExceeded(); + return await _insertWithRetry(cacheObject, setTouchedToNow, + retries: retries - 1); + } + rethrow; + } } @override From 90b6ded87dc830de1909e67ba0f76003b12dcdb2 Mon Sep 17 00:00:00 2001 From: Ben Milanko Date: Mon, 27 Oct 2025 21:45:42 +1100 Subject: [PATCH 07/20] Fix failing tests --- flutter_cache_manager/lib/src/config/_config_web.dart | 5 +++-- .../cache_info_repositories/cache_info_repositories.dart | 5 ++++- .../lib/src/storage/file_system/file_system.dart | 7 ++----- .../lib/src/storage/file_system/file_system_web.dart | 4 ++++ 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/flutter_cache_manager/lib/src/config/_config_web.dart b/flutter_cache_manager/lib/src/config/_config_web.dart index 793cbabf..e24d15a9 100644 --- a/flutter_cache_manager/lib/src/config/_config_web.dart +++ b/flutter_cache_manager/lib/src/config/_config_web.dart @@ -1,7 +1,8 @@ import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_cache_manager/src/config/config.dart' as def; - -import '../storage/file_system/file_system.dart'; +import 'package:flutter_cache_manager/src/storage/cache_info_repositories/indexed_db_cache_info_repository.dart'; +import 'package:flutter_cache_manager/src/storage/file_system/file_system.dart'; +import 'package:flutter_cache_manager/src/storage/file_system/indexed_db_file_system.dart'; class Config implements def.Config { Config( diff --git a/flutter_cache_manager/lib/src/storage/cache_info_repositories/cache_info_repositories.dart b/flutter_cache_manager/lib/src/storage/cache_info_repositories/cache_info_repositories.dart index 30133191..b2e77fb4 100644 --- a/flutter_cache_manager/lib/src/storage/cache_info_repositories/cache_info_repositories.dart +++ b/flutter_cache_manager/lib/src/storage/cache_info_repositories/cache_info_repositories.dart @@ -1,5 +1,8 @@ export 'cache_info_repository.dart'; export 'cache_object_provider.dart'; -export 'indexed_db_cache_info_repository.dart'; export 'json_cache_info_repository.dart'; export 'non_storing_object_provider.dart'; + +// Note: indexed_db_cache_info_repository.dart is web-only and not exported here +// to avoid dart:js_interop import errors on non-web platforms. +// Web code should import it directly when needed. diff --git a/flutter_cache_manager/lib/src/storage/file_system/file_system.dart b/flutter_cache_manager/lib/src/storage/file_system/file_system.dart index 817124ed..1a63f09d 100644 --- a/flutter_cache_manager/lib/src/storage/file_system/file_system.dart +++ b/flutter_cache_manager/lib/src/storage/file_system/file_system.dart @@ -1,10 +1,7 @@ import 'package:file/file.dart'; -export 'file_system.dart'; -export 'file_system_io.dart'; -export 'file_system_web.dart'; -export 'indexed_db_file.dart'; -export 'indexed_db_file_system.dart'; +export 'file_system_io.dart' + if (dart.library.js_interop) 'file_system_web.dart'; abstract class FileSystem { Future createFile(String name); diff --git a/flutter_cache_manager/lib/src/storage/file_system/file_system_web.dart b/flutter_cache_manager/lib/src/storage/file_system/file_system_web.dart index ac080452..d7d888fb 100644 --- a/flutter_cache_manager/lib/src/storage/file_system/file_system_web.dart +++ b/flutter_cache_manager/lib/src/storage/file_system/file_system_web.dart @@ -2,6 +2,10 @@ import 'package:file/file.dart' show File; import 'package:file/memory.dart'; import 'package:flutter_cache_manager/src/storage/file_system/file_system.dart'; +// Export web-specific implementations +export 'indexed_db_file.dart'; +export 'indexed_db_file_system.dart'; + class MemoryCacheSystem implements FileSystem { final directory = MemoryFileSystem().systemTempDirectory.createTemp('cache'); From 43a433a26d88dc4f35f23ff8d01f028491206c1f Mon Sep 17 00:00:00 2001 From: Ian <79951156+joseianpatrick@users.noreply.github.com> Date: Tue, 28 Oct 2025 14:24:46 +0800 Subject: [PATCH 08/20] update, test stub --- .../storage/file_system/file_system_web.dart | 8 +- .../file_system/indexed_db_file_stub.dart | 175 ++++++++++++++++++ .../indexed_db_file_system_stub.dart | 13 ++ 3 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 flutter_cache_manager/lib/src/storage/file_system/indexed_db_file_stub.dart create mode 100644 flutter_cache_manager/lib/src/storage/file_system/indexed_db_file_system_stub.dart diff --git a/flutter_cache_manager/lib/src/storage/file_system/file_system_web.dart b/flutter_cache_manager/lib/src/storage/file_system/file_system_web.dart index d7d888fb..8e0b3e91 100644 --- a/flutter_cache_manager/lib/src/storage/file_system/file_system_web.dart +++ b/flutter_cache_manager/lib/src/storage/file_system/file_system_web.dart @@ -2,9 +2,11 @@ import 'package:file/file.dart' show File; import 'package:file/memory.dart'; import 'package:flutter_cache_manager/src/storage/file_system/file_system.dart'; -// Export web-specific implementations -export 'indexed_db_file.dart'; -export 'indexed_db_file_system.dart'; +// Web-specific implementations: export on web only, export stubs elsewhere +export 'indexed_db_file_stub.dart' + if (dart.library.js_interop) 'indexed_db_file.dart'; +export 'indexed_db_file_system_stub.dart' + if (dart.library.js_interop) 'indexed_db_file_system.dart'; class MemoryCacheSystem implements FileSystem { final directory = MemoryFileSystem().systemTempDirectory.createTemp('cache'); diff --git a/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file_stub.dart b/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file_stub.dart new file mode 100644 index 00000000..2c032cc1 --- /dev/null +++ b/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file_stub.dart @@ -0,0 +1,175 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:file/file.dart'; + +// Non-web stub to satisfy conditional export when js_interop is unavailable +class IndexedDbFile implements File { + IndexedDbFile(String path, String dbName); + + @override + File get absolute => this; + + @override + Future copy(String newPath) => _unsupported(); + + @override + File copySync(String newPath) => _throw(); + + @override + Future create({bool recursive = false, bool exclusive = false}) => + _unsupported(); + + @override + void createSync({bool recursive = false, bool exclusive = false}) => _throw(); + + @override + Future delete({bool recursive = false}) => _unsupported(); + + @override + void deleteSync({bool recursive = false}) => _throw(); + + @override + bool existsSync() => _throw(); + + @override + Future exists() => _unsupported(); + + @override + bool get isAbsolute => true; + + @override + RandomAccessFile openSync({FileMode mode = FileMode.read}) => _throw(); + + @override + Future open({FileMode mode = FileMode.read}) => + _unsupported(); + + @override + Stream> openRead([int? start, int? end]) => + Stream>.error(_unsupportedError()); + + @override + IOSink openWrite( + {FileMode mode = FileMode.write, Encoding encoding = utf8}) => + _throw(); + + @override + Directory get parent => _throw(); + + @override + String get path => _throw(); + + @override + Future length() => _unsupported(); + + @override + int lengthSync() => _throw(); + + @override + Future readAsBytes() => _unsupported(); + + @override + Uint8List readAsBytesSync() => _throw(); + + @override + Future readAsString({Encoding encoding = utf8}) => _unsupported(); + + @override + String readAsStringSync({Encoding encoding = utf8}) => _throw(); + + @override + Future> readAsLines({Encoding encoding = utf8}) => + _unsupported(); + + @override + List readAsLinesSync({Encoding encoding = utf8}) => _throw(); + + @override + Future rename(String newPath) => _unsupported(); + + @override + File renameSync(String newPath) => _throw(); + + @override + Future resolveSymbolicLinks() => _unsupported(); + + @override + String resolveSymbolicLinksSync() => _throw(); + + @override + Future lastAccessed() => _unsupported(); + + @override + DateTime lastAccessedSync() => _throw(); + + @override + Future lastModified() => _unsupported(); + + @override + DateTime lastModifiedSync() => _throw(); + + @override + FileStat statSync() => _throw(); + + @override + Future stat() => _unsupported(); + + @override + Uri get uri => _throw(); + + @override + String get basename => _throw(); + + @override + String get dirname => _throw(); + + @override + Stream watch( + {int events = FileSystemEvent.all, bool recursive = false}) => + Stream.error(_unsupportedError()); + + @override + Future writeAsBytes(List bytes, + {FileMode mode = FileMode.write, bool flush = false}) => + _unsupported(); + + @override + void writeAsBytesSync(List bytes, + {FileMode mode = FileMode.write, bool flush = false}) => + _throw(); + + @override + Future writeAsString(String contents, + {FileMode mode = FileMode.write, + Encoding encoding = utf8, + bool flush = false}) => + _unsupported(); + + @override + void writeAsStringSync(String contents, + {FileMode mode = FileMode.write, + Encoding encoding = utf8, + bool flush = false}) => + _throw(); + + @override + void setLastAccessedSync(DateTime time) => _throw(); + + @override + Future setLastAccessed(DateTime time) => _unsupported(); + + @override + void setLastModifiedSync(DateTime time) => _throw(); + + @override + Future setLastModified(DateTime time) => _unsupported(); + + T _throw() => throw _unsupportedError(); + Future _unsupported() => Future.error(_unsupportedError()); + UnsupportedError _unsupportedError() => + UnsupportedError('IndexedDbFile is only available on web'); + + @override + FileSystem get fileSystem => _throw(); +} diff --git a/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file_system_stub.dart b/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file_system_stub.dart new file mode 100644 index 00000000..0177d33e --- /dev/null +++ b/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file_system_stub.dart @@ -0,0 +1,13 @@ +import 'package:file/file.dart' as file_pkg; +import 'package:flutter_cache_manager/src/storage/file_system/file_system.dart' + as cache_fs; + +// Non-web stub to satisfy conditional export when js_interop is unavailable +class IndexedDbFileSystem implements cache_fs.FileSystem { + IndexedDbFileSystem(String databaseName); + + @override + Future createFile(String name) async { + throw UnsupportedError('IndexedDbFileSystem is only available on web'); + } +} From 2130b3418ccd15e747f2aedcca5b1cf0f0406cbe Mon Sep 17 00:00:00 2001 From: Ben Milanko Date: Tue, 28 Oct 2025 20:54:41 +1100 Subject: [PATCH 09/20] Handle exist sync --- flutter_cache_manager/lib/src/cache_store.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/flutter_cache_manager/lib/src/cache_store.dart b/flutter_cache_manager/lib/src/cache_store.dart index 66d959f8..26b870b2 100644 --- a/flutter_cache_manager/lib/src/cache_store.dart +++ b/flutter_cache_manager/lib/src/cache_store.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'storage/file_system/file_system.dart'; @@ -187,7 +188,7 @@ class CacheStore { } final file = await fileSystem.createFile(cacheObject.relativePath); - if (file.existsSync()) { + if (kIsWeb ? await file.exists() : file.existsSync()) { try { await file.delete(); // ignore: unused_catch_clause From 4a83588666c4be9224d432ff26f526b5da3eb3b8 Mon Sep 17 00:00:00 2001 From: Ben Milanko Date: Sat, 5 Sep 2026 13:55:54 +1000 Subject: [PATCH 10/20] Upgrade dependencies and document fork sync Raise direct dependency floors to the latest published versions (clock 1.1.3, path_provider 2.1.6, sqflite 2.4.3, build_runner 2.16.1, mockito 5.8.1, web 1.1.1) and record the fork's IndexedDB web support alongside the upstream sync in the changelog. Also picks up the analyzer excludes the Flutter 3.47 toolchain writes into analysis_options.yaml on pub get. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TCmKSRuHNETmfGu2TffVzo --- flutter_cache_manager/CHANGELOG.md | 10 ++++++++++ flutter_cache_manager/analysis_options.yaml | 9 +++++++++ flutter_cache_manager/example/analysis_options.yaml | 9 +++++++++ flutter_cache_manager/pubspec.yaml | 10 +++++----- flutter_cache_manager_firebase/analysis_options.yaml | 9 +++++++++ 5 files changed, 42 insertions(+), 5 deletions(-) diff --git a/flutter_cache_manager/CHANGELOG.md b/flutter_cache_manager/CHANGELOG.md index 97c1a155..173ca7c3 100644 --- a/flutter_cache_manager/CHANGELOG.md +++ b/flutter_cache_manager/CHANGELOG.md @@ -1,5 +1,15 @@ ## [Unreleased] +### Fork (Railway-Engineering-Solutions) + +* Adds IndexedDB-backed cache info repository and file system for web, replacing the + non-persistent `NonStoringObjectProvider` / `MemoryCacheSystem` defaults +* Syncs with Baseflow upstream `develop` (through 3.4.2 + unreleased changes) +* Raises dependency floors: `clock` 1.1.3, `path_provider` 2.1.6, `sqflite` 2.4.3, + `build_runner` 2.16.1, `mockito` 5.8.1, `web` 1.1.1 + +### Upstream + * Fixes `JsonCacheInfoRepository` losing metadata when the app exits within 3 seconds of a cache change by writing through promptly with serialized, atomic file writes ([#491](https://github.com/Baseflow/flutter_cache_manager/issues/491)) * Modernizes GitHub Actions CI (combined quality job, pinned Flutter 3.44.4, Dependabot for actions) * Updates example Android project to AGP 9.0.1 / Gradle 9.1 / Kotlin 2.3.20 diff --git a/flutter_cache_manager/analysis_options.yaml b/flutter_cache_manager/analysis_options.yaml index f9b30346..743e05ad 100644 --- a/flutter_cache_manager/analysis_options.yaml +++ b/flutter_cache_manager/analysis_options.yaml @@ -1 +1,10 @@ +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:flutter_lints/flutter.yaml diff --git a/flutter_cache_manager/example/analysis_options.yaml b/flutter_cache_manager/example/analysis_options.yaml index f9b30346..743e05ad 100644 --- a/flutter_cache_manager/example/analysis_options.yaml +++ b/flutter_cache_manager/example/analysis_options.yaml @@ -1 +1,10 @@ +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:flutter_lints/flutter.yaml diff --git a/flutter_cache_manager/pubspec.yaml b/flutter_cache_manager/pubspec.yaml index 7780dfae..9845dee3 100644 --- a/flutter_cache_manager/pubspec.yaml +++ b/flutter_cache_manager/pubspec.yaml @@ -9,25 +9,25 @@ environment: sdk: '>=3.8.0 <4.0.0' dependencies: - clock: ^1.1.2 + clock: ^1.1.3 collection: ^1.19.1 file: ^7.0.1 flutter: sdk: flutter http: ^1.6.0 path: ^1.9.1 - path_provider: ^2.1.5 + path_provider: ^2.1.6 rxdart: ^0.28.0 - sqflite: ^2.4.2 + sqflite: ^2.4.3 uuid: ^4.6.0 web: ^1.1.1 dev_dependencies: - build_runner: ^2.14.0 + build_runner: ^2.16.1 flutter_lints: ^6.0.0 flutter_test: sdk: flutter - mockito: ^5.7.0 + mockito: ^5.8.1 platforms: android: diff --git a/flutter_cache_manager_firebase/analysis_options.yaml b/flutter_cache_manager_firebase/analysis_options.yaml index f9b30346..743e05ad 100644 --- a/flutter_cache_manager_firebase/analysis_options.yaml +++ b/flutter_cache_manager_firebase/analysis_options.yaml @@ -1 +1,10 @@ +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:flutter_lints/flutter.yaml From 51dacc8d352a43c0cb4236ee5a25d82bc1d7f02c Mon Sep 17 00:00:00 2001 From: Ben Milanko Date: Sat, 5 Sep 2026 13:59:21 +1000 Subject: [PATCH 11/20] Format fork IndexedDB sources with dart format Upstream CI now runs `dart format --set-exit-if-changed .`, and the fork's IndexedDB repository, connection pool, file system and example were still in the pre-3.7 style. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TCmKSRuHNETmfGu2TffVzo --- .../example/lib/test_indexeddb.dart | 9 +-- .../indexed_db_cache_info_repository.dart | 43 +++++++----- .../indexed_db_connection_pool.dart | 3 +- .../storage/file_system/indexed_db_file.dart | 65 ++++++++++--------- .../file_system/indexed_db_file_stub.dart | 52 ++++++++------- 5 files changed, 95 insertions(+), 77 deletions(-) diff --git a/flutter_cache_manager/example/lib/test_indexeddb.dart b/flutter_cache_manager/example/lib/test_indexeddb.dart index 2eb93935..c06a3349 100644 --- a/flutter_cache_manager/example/lib/test_indexeddb.dart +++ b/flutter_cache_manager/example/lib/test_indexeddb.dart @@ -11,10 +11,7 @@ void main() { class IndexedDBTestApp extends MaterialApp { const IndexedDBTestApp({super.key}) - : super( - home: const IndexedDBTestPage(), - title: 'IndexedDB Cache Test', - ); + : super(home: const IndexedDBTestPage(), title: 'IndexedDB Cache Test'); } class IndexedDBTestPage extends StatefulWidget { @@ -62,9 +59,7 @@ class _IndexedDBTestPageState extends State { testUrl, fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) { - return const Center( - child: Text('Failed to load image'), - ); + return const Center(child: Text('Failed to load image')); }, ), ), diff --git a/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_cache_info_repository.dart b/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_cache_info_repository.dart index 5af41de2..d7349910 100644 --- a/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_cache_info_repository.dart +++ b/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_cache_info_repository.dart @@ -115,11 +115,7 @@ class IndexedDbCacheInfoRepository extends CacheInfoRepository // Try to create transaction with relaxed durability (modern browsers) // Note: The durability hint may not be available in all browser versions final options = web.IDBTransactionOptions(); - return db.transaction( - storeName.toJS, - mode, - options, - ); + return db.transaction(storeName.toJS, mode, options); } catch (e) { // Fallback for older browsers that don't support transaction options return db.transaction(storeName.toJS, mode); @@ -150,8 +146,10 @@ class IndexedDbCacheInfoRepository extends CacheInfoRepository allObjects.sort((a, b) => a.touched!.compareTo(b.touched!)); final toRemoveCount = (allObjects.length * 0.1).ceil().clamp(1, 50); - final toRemove = - allObjects.take(toRemoveCount).map((e) => e.id!).toList(); + final toRemove = allObjects + .take(toRemoveCount) + .map((e) => e.id!) + .toList(); await deleteAll(toRemove); @@ -284,8 +282,11 @@ class IndexedDbCacheInfoRepository extends CacheInfoRepository if (_isQuotaExceededError(e) && retries > 0) { // Try to free up space and retry once await _handleQuotaExceeded(); - return await _insertWithRetry(cacheObject, setTouchedToNow, - retries: retries - 1); + return await _insertWithRetry( + cacheObject, + setTouchedToNow, + retries: retries - 1, + ); } rethrow; } @@ -333,8 +334,11 @@ class IndexedDbCacheInfoRepository extends CacheInfoRepository final completer = Completer>(); try { - final transaction = - _createTransaction(db, _metadataStoreName, 'readonly'); + final transaction = _createTransaction( + db, + _metadataStoreName, + 'readonly', + ); final store = transaction.objectStore(_metadataStoreName); // First, get the count to determine if we're over capacity @@ -406,8 +410,11 @@ class IndexedDbCacheInfoRepository extends CacheInfoRepository final completer = Completer>(); try { - final transaction = - _createTransaction(db, _metadataStoreName, 'readonly'); + final transaction = _createTransaction( + db, + _metadataStoreName, + 'readonly', + ); final store = transaction.objectStore(_metadataStoreName); // Use the touched index to efficiently find old objects @@ -485,8 +492,11 @@ class IndexedDbCacheInfoRepository extends CacheInfoRepository final completer = Completer(); try { - final transaction = - _createTransaction(db, _metadataStoreName, 'readwrite'); + final transaction = _createTransaction( + db, + _metadataStoreName, + 'readwrite', + ); final store = transaction.objectStore(_metadataStoreName); // Queue all delete operations in the transaction @@ -555,8 +565,7 @@ class IndexedDbCacheInfoRepository extends CacheInfoRepository request.onblocked = (web.Event e) { // Database deletion is blocked by open connections // This shouldn't happen as we closed the connection above - } - .toJS; + }.toJS; return completer.future; } diff --git a/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_connection_pool.dart b/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_connection_pool.dart index aa4b7cb2..88506388 100644 --- a/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_connection_pool.dart +++ b/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_connection_pool.dart @@ -94,8 +94,7 @@ class IndexedDbConnectionPool { request.onblocked = (web.Event e) { // Another connection is blocking the upgrade // This is usually because another tab has an older version open - } - .toJS; + }.toJS; return await _openingCompleter!.future; } catch (e) { diff --git a/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file.dart b/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file.dart index 649e27ed..f6551e38 100644 --- a/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file.dart +++ b/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file.dart @@ -20,10 +20,10 @@ class IndexedDbFile implements File { late final IndexedDbConnectionPool _connectionPool = IndexedDbConnectionPool.getInstance( - databaseName: _dbName, - version: _dbVersion, - onUpgrade: _onUpgradeNeeded, - ); + databaseName: _dbName, + version: _dbVersion, + onUpgrade: _onUpgradeNeeded, + ); void _onUpgradeNeeded(web.IDBDatabase db, web.IDBVersionChangeEvent e) { final oldVersion = e.oldVersion; @@ -100,11 +100,7 @@ class IndexedDbFile implements File { // Try to create transaction with relaxed durability (modern browsers) // Note: The durability hint may not be available in all browser versions final options = web.IDBTransactionOptions(); - return db.transaction( - storeName.toJS, - mode, - options, - ); + return db.transaction(storeName.toJS, mode, options); } catch (e) { // Fallback for older browsers that don't support transaction options return db.transaction(storeName.toJS, mode); @@ -148,8 +144,11 @@ class IndexedDbFile implements File { } @override - Future writeAsBytes(List bytes, - {FileMode mode = FileMode.write, bool flush = false}) async { + Future writeAsBytes( + List bytes, { + FileMode mode = FileMode.write, + bool flush = false, + }) async { final db = await _getDatabase(); final completer = Completer(); final transaction = _createTransaction(db, _fileStoreName, 'readwrite'); @@ -157,10 +156,7 @@ class IndexedDbFile implements File { final data = bytes is Uint8List ? bytes : Uint8List.fromList(bytes); - final fileObject = { - 'path': _path, - 'data': data, - }.jsify(); + final fileObject = {'path': _path, 'data': data}.jsify(); final request = store.put(fileObject); @@ -329,8 +325,10 @@ class IndexedDbFile implements File { } @override - Stream watch( - {int events = FileSystemEvent.all, bool recursive = false}) { + Stream watch({ + int events = FileSystemEvent.all, + bool recursive = false, + }) { throw UnsupportedError('watch is not supported for IndexedDbFile'); } @@ -372,25 +370,32 @@ class IndexedDbFile implements File { } @override - Future writeAsString(String contents, - {FileMode mode = FileMode.write, - Encoding encoding = utf8, - bool flush = false}) async { + Future writeAsString( + String contents, { + FileMode mode = FileMode.write, + Encoding encoding = utf8, + bool flush = false, + }) async { final bytes = encoding.encode(contents); return writeAsBytes(bytes, mode: mode, flush: flush); } @override - void writeAsStringSync(String contents, - {FileMode mode = FileMode.write, - Encoding encoding = utf8, - bool flush = false}) { + void writeAsStringSync( + String contents, { + FileMode mode = FileMode.write, + Encoding encoding = utf8, + bool flush = false, + }) { throw UnsupportedError('writeAsStringSync is not supported on web'); } @override - void writeAsBytesSync(List bytes, - {FileMode mode = FileMode.write, bool flush = false}) { + void writeAsBytesSync( + List bytes, { + FileMode mode = FileMode.write, + bool flush = false, + }) { throw UnsupportedError('writeAsBytesSync is not supported on web'); } @@ -417,7 +422,8 @@ class IndexedDbFile implements File { @override Future setLastAccessed(DateTime time) { throw UnsupportedError( - 'setLastAccessed is not supported for IndexedDbFile'); + 'setLastAccessed is not supported for IndexedDbFile', + ); } @override @@ -428,7 +434,8 @@ class IndexedDbFile implements File { @override Future setLastModified(DateTime time) { throw UnsupportedError( - 'setLastModified is not supported for IndexedDbFile'); + 'setLastModified is not supported for IndexedDbFile', + ); } @override diff --git a/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file_stub.dart b/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file_stub.dart index 2c032cc1..1c6751cb 100644 --- a/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file_stub.dart +++ b/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file_stub.dart @@ -50,9 +50,10 @@ class IndexedDbFile implements File { Stream>.error(_unsupportedError()); @override - IOSink openWrite( - {FileMode mode = FileMode.write, Encoding encoding = utf8}) => - _throw(); + IOSink openWrite({ + FileMode mode = FileMode.write, + Encoding encoding = utf8, + }) => _throw(); @override Directory get parent => _throw(); @@ -125,33 +126,40 @@ class IndexedDbFile implements File { String get dirname => _throw(); @override - Stream watch( - {int events = FileSystemEvent.all, bool recursive = false}) => - Stream.error(_unsupportedError()); + Stream watch({ + int events = FileSystemEvent.all, + bool recursive = false, + }) => Stream.error(_unsupportedError()); @override - Future writeAsBytes(List bytes, - {FileMode mode = FileMode.write, bool flush = false}) => - _unsupported(); + Future writeAsBytes( + List bytes, { + FileMode mode = FileMode.write, + bool flush = false, + }) => _unsupported(); @override - void writeAsBytesSync(List bytes, - {FileMode mode = FileMode.write, bool flush = false}) => - _throw(); + void writeAsBytesSync( + List bytes, { + FileMode mode = FileMode.write, + bool flush = false, + }) => _throw(); @override - Future writeAsString(String contents, - {FileMode mode = FileMode.write, - Encoding encoding = utf8, - bool flush = false}) => - _unsupported(); + Future writeAsString( + String contents, { + FileMode mode = FileMode.write, + Encoding encoding = utf8, + bool flush = false, + }) => _unsupported(); @override - void writeAsStringSync(String contents, - {FileMode mode = FileMode.write, - Encoding encoding = utf8, - bool flush = false}) => - _throw(); + void writeAsStringSync( + String contents, { + FileMode mode = FileMode.write, + Encoding encoding = utf8, + bool flush = false, + }) => _throw(); @override void setLastAccessedSync(DateTime time) => _throw(); From d4e395c137261d76e3af9c3d6589476882917f96 Mon Sep 17 00:00:00 2001 From: Ben Milanko Date: Sat, 5 Sep 2026 14:01:23 +1000 Subject: [PATCH 12/20] Bump CI to Flutter 3.47.1 and clear analyzer findings CI pinned Flutter 3.44.4 while the packages now build against 3.47.1. Raising the pin surfaces two use_super_parameters findings in upstream code, so convert both constructors to super parameters and drop the web_helper import that becomes redundant as a result. `flutter analyze` is clean for both packages on 3.47.1. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TCmKSRuHNETmfGu2TffVzo --- .github/workflows/build-firebase.yaml | 2 +- .github/workflows/build.yaml | 2 +- flutter_cache_manager/lib/src/web/web_helper.dart | 3 +-- flutter_cache_manager/test/cache_manager_test.dart | 9 ++------- 4 files changed, 5 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build-firebase.yaml b/.github/workflows/build-firebase.yaml index bc308492..65ed5626 100644 --- a/.github/workflows/build-firebase.yaml +++ b/.github/workflows/build-firebase.yaml @@ -24,7 +24,7 @@ concurrency: cancel-in-progress: true env: - FLUTTER_VERSION: "3.44.4" + FLUTTER_VERSION: "3.47.1" jobs: quality: diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index d9f4a1c0..f5e8853e 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -24,7 +24,7 @@ concurrency: cancel-in-progress: true env: - FLUTTER_VERSION: "3.44.4" + FLUTTER_VERSION: "3.47.1" jobs: quality: diff --git a/flutter_cache_manager/lib/src/web/web_helper.dart b/flutter_cache_manager/lib/src/web/web_helper.dart index 126a2c8f..2144a492 100644 --- a/flutter_cache_manager/lib/src/web/web_helper.dart +++ b/flutter_cache_manager/lib/src/web/web_helper.dart @@ -239,8 +239,7 @@ class WebHelper { } class HttpExceptionWithStatus extends HttpException { - const HttpExceptionWithStatus(this.statusCode, String message, {Uri? uri}) - : super(message, uri: uri); + const HttpExceptionWithStatus(this.statusCode, super.message, {super.uri}); final int statusCode; } diff --git a/flutter_cache_manager/test/cache_manager_test.dart b/flutter_cache_manager/test/cache_manager_test.dart index 95242aa0..89e94737 100644 --- a/flutter_cache_manager/test/cache_manager_test.dart +++ b/flutter_cache_manager/test/cache_manager_test.dart @@ -5,7 +5,6 @@ import 'package:clock/clock.dart'; import 'package:file/memory.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_cache_manager/src/cache_store.dart'; -import 'package:flutter_cache_manager/src/web/web_helper.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; @@ -623,10 +622,6 @@ void main() { } class TestCacheManager extends CacheManager with ImageCacheManager { - TestCacheManager(Config? config, {CacheStore? store, WebHelper? webHelper}) - : super.custom( - config ?? createTestConfig(), - cacheStore: store, - webHelper: webHelper, - ); + TestCacheManager(Config? config, {CacheStore? store, super.webHelper}) + : super.custom(config ?? createTestConfig(), cacheStore: store); } From a1ea8cd18b6a26b16d4f317ac720d79f28c1ee68 Mon Sep 17 00:00:00 2001 From: Ben Milanko Date: Tue, 16 Dec 2025 17:44:09 +1100 Subject: [PATCH 13/20] Add cancellation support to file fetching operations - Introduced `CancellationToken` class to manage request cancellations. - Updated `CacheManager`, `BaseCacheManager`, and `FileService` interfaces to accept `CancellationToken`. - Enhanced file fetching methods to throw `CancelledException` when requests are cancelled. - Added tests for cancellation functionality in `HttpFileService` and `CancellationToken` behavior. --- .../lib/src/cache_manager.dart | 24 ++++- .../cache_managers/base_cache_manager.dart | 15 ++-- .../lib/src/compat/file_service_compat.dart | 7 ++ .../lib/src/web/file_service.dart | 73 ++++++++++++++-- .../lib/src/web/queue_item.dart | 5 +- .../lib/src/web/web_helper.dart | 28 ++++-- .../test/http_file_fetcher_test.dart | 87 +++++++++++++++++++ .../lib/src/firebase_http_file_service.dart | 7 +- 8 files changed, 226 insertions(+), 20 deletions(-) diff --git a/flutter_cache_manager/lib/src/cache_manager.dart b/flutter_cache_manager/lib/src/cache_manager.dart index c04b9e7a..707bec2f 100644 --- a/flutter_cache_manager/lib/src/cache_manager.dart +++ b/flutter_cache_manager/lib/src/cache_manager.dart @@ -70,13 +70,19 @@ class CacheManager implements BaseCacheManager { String url, { String? key, Map? headers, + CancellationToken? cancellationToken, }) async { key ??= url; final cacheFile = await getFileFromCache(key); if (cacheFile != null && cacheFile.validTill.isAfter(DateTime.now())) { return cacheFile.file; } - return (await downloadFile(url, key: key, authHeaders: headers)).file; + return (await downloadFile( + url, + key: key, + authHeaders: headers, + cancellationToken: cancellationToken, + )).file; } /// Get the file from the cache and/or online, depending on availability and age. @@ -89,11 +95,13 @@ class CacheManager implements BaseCacheManager { String url, { String? key, Map? headers, + CancellationToken? cancellationToken, }) { return getFileStream( url, key: key, withProgress: false, + cancellationToken: cancellationToken, ).where((r) => r is FileInfo).cast(); } @@ -114,10 +122,18 @@ class CacheManager implements BaseCacheManager { String? key, Map? headers, bool withProgress = false, + CancellationToken? cancellationToken, }) { key ??= url; final streamController = StreamController(); - _pushFileToStream(streamController, url, key, headers, withProgress); + _pushFileToStream( + streamController, + url, + key, + headers, + withProgress, + cancellationToken, + ); return streamController.stream; } @@ -127,6 +143,7 @@ class CacheManager implements BaseCacheManager { String? key, Map? headers, bool withProgress, + CancellationToken? cancellationToken, ) async { key ??= url; FileInfo? cacheFile; @@ -148,6 +165,7 @@ class CacheManager implements BaseCacheManager { url, key: key, authHeaders: headers, + cancellationToken: cancellationToken, )) { if (response is DownloadProgress && withProgress) { streamController.add(response); @@ -185,6 +203,7 @@ class CacheManager implements BaseCacheManager { String? key, Map? authHeaders, bool force = false, + CancellationToken? cancellationToken, }) async { key ??= url; final fileResponse = await _webHelper @@ -193,6 +212,7 @@ class CacheManager implements BaseCacheManager { key: key, authHeaders: authHeaders, ignoreMemCache: force, + cancellationToken: cancellationToken, ) .firstWhere((r) => r is FileInfo); return fileResponse as FileInfo; diff --git a/flutter_cache_manager/lib/src/cache_managers/base_cache_manager.dart b/flutter_cache_manager/lib/src/cache_managers/base_cache_manager.dart index c6f3a59d..71e8e785 100644 --- a/flutter_cache_manager/lib/src/cache_managers/base_cache_manager.dart +++ b/flutter_cache_manager/lib/src/cache_managers/base_cache_manager.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; import 'package:file/file.dart'; import 'package:flutter_cache_manager/src/result/file_info.dart'; import 'package:flutter_cache_manager/src/result/file_response.dart'; +import 'package:flutter_cache_manager/src/web/file_service.dart'; /// Interface of the CacheManager. In general [CacheManager] can be used /// directly. @@ -14,8 +15,9 @@ abstract class BaseCacheManager { /// newly downloaded file is returned. Future getSingleFile( String url, { - String key, - Map headers, + String? key, + Map? headers, + CancellationToken? cancellationToken, }); /// Get the file from the cache and/or online, depending on availability and age. @@ -25,8 +27,9 @@ abstract class BaseCacheManager { @Deprecated('Prefer to use the new getFileStream method') Stream getFile( String url, { - String key, - Map headers, + String? key, + Map? headers, + CancellationToken? cancellationToken, }); /// Get the file from the cache and/or online, depending on availability and age. @@ -44,7 +47,8 @@ abstract class BaseCacheManager { String url, { String? key, Map? headers, - bool withProgress, + bool withProgress = false, + CancellationToken? cancellationToken, }); ///Download the file and add to cache @@ -53,6 +57,7 @@ abstract class BaseCacheManager { String? key, Map? authHeaders, bool force = false, + CancellationToken? cancellationToken, }); /// Get the file from the cache. 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..92656fe8 100644 --- a/flutter_cache_manager/lib/src/compat/file_service_compat.dart +++ b/flutter_cache_manager/lib/src/compat/file_service_compat.dart @@ -13,8 +13,15 @@ class FileServiceCompat extends FileService { Future get( String url, { Map? headers, + CancellationToken? cancellationToken, }) async { + if (cancellationToken?.isCancelled ?? false) { + throw CancelledException(); + } final legacyResponse = await fileFetcher(url, headers: headers); + if (cancellationToken?.isCancelled ?? false) { + throw CancelledException(); + } return CompatFileServiceGetResponse(legacyResponse); } } diff --git a/flutter_cache_manager/lib/src/web/file_service.dart b/flutter_cache_manager/lib/src/web/file_service.dart index 376d672e..f5503bf9 100644 --- a/flutter_cache_manager/lib/src/web/file_service.dart +++ b/flutter_cache_manager/lib/src/web/file_service.dart @@ -9,6 +9,40 @@ import 'package:http/http.dart' as http; ///Copyright (c) 2019 Rene Floor ///Released under MIT License. +/// Token that can be used to cancel an in-flight request. +class CancellationToken { + bool _isCancelled = false; + Completer? _completer; + + /// Whether this token has been cancelled. + bool get isCancelled => _isCancelled; + + /// Cancel the request associated with this token. + void cancel() { + _isCancelled = true; + _completer?.complete(); + } + + /// A future that completes when this token is cancelled. + Future get whenCancelled { + _completer ??= Completer(); + if (_isCancelled) _completer!.complete(); + return _completer!.future; + } +} + +/// Exception thrown when a request is cancelled. +class CancelledException implements Exception { + /// Creates a new [CancelledException] with an optional message. + CancelledException([this.message = 'Request was cancelled']); + + /// The error message. + final String message; + + @override + String toString() => 'CancelledException: $message'; +} + /// Defines the interface for a file service. /// Most common file service will be an [HttpFileService], however one can /// also make something more specialized. For example you could fetch files @@ -16,29 +50,56 @@ import 'package:http/http.dart' as http; abstract class FileService { int concurrentFetches = 10; - Future get(String url, {Map? headers}); + Future get( + String url, { + Map? headers, + CancellationToken? cancellationToken, + }); } /// [HttpFileService] is the most common file service and the default for /// [WebHelper]. One can easily adapt it to use dio or any other http client. class HttpFileService extends FileService { - final http.Client _httpClient; + final http.Client? _httpClient; - HttpFileService({http.Client? httpClient}) - : _httpClient = httpClient ?? http.Client(); + HttpFileService({http.Client? httpClient}) : _httpClient = httpClient; @override Future get( String url, { Map? headers, + CancellationToken? cancellationToken, }) async { + if (cancellationToken?.isCancelled ?? false) { + throw CancelledException(); + } + final req = http.Request('GET', Uri.parse(url)); if (headers != null) { req.headers.addAll(headers); } - final httpResponse = await _httpClient.send(req); - return HttpGetResponse(httpResponse); + // Use dedicated client for cancellable requests, or shared client + final client = cancellationToken != null + ? http.Client() + : (_httpClient ?? http.Client()); + + if (cancellationToken != null) { + cancellationToken.whenCancelled.then((_) => client.close()); + } + + try { + final httpResponse = await client.send(req); + if (cancellationToken?.isCancelled ?? false) { + throw CancelledException(); + } + return HttpGetResponse(httpResponse); + } catch (e) { + if (cancellationToken?.isCancelled ?? false) { + throw CancelledException(); + } + rethrow; + } } } diff --git a/flutter_cache_manager/lib/src/web/queue_item.dart b/flutter_cache_manager/lib/src/web/queue_item.dart index ebfdea6b..239968aa 100644 --- a/flutter_cache_manager/lib/src/web/queue_item.dart +++ b/flutter_cache_manager/lib/src/web/queue_item.dart @@ -1,7 +1,10 @@ +import 'package:flutter_cache_manager/src/web/file_service.dart'; + class QueueItem { final String url; final String key; final Map? headers; + final CancellationToken? cancellationToken; - const QueueItem(this.url, this.key, this.headers); + const QueueItem(this.url, this.key, this.headers, [this.cancellationToken]); } diff --git a/flutter_cache_manager/lib/src/web/web_helper.dart b/flutter_cache_manager/lib/src/web/web_helper.dart index 2144a492..cebe59c4 100644 --- a/flutter_cache_manager/lib/src/web/web_helper.dart +++ b/flutter_cache_manager/lib/src/web/web_helper.dart @@ -34,13 +34,14 @@ class WebHelper { String? key, Map? authHeaders, bool ignoreMemCache = false, + CancellationToken? cancellationToken, }) { key ??= url; var subject = _memCache[key]; if (subject == null || ignoreMemCache) { subject = BehaviorSubject(); _memCache[key] = subject; - _downloadOrAddToQueue(url, key, authHeaders); + _downloadOrAddToQueue(url, key, authHeaders, cancellationToken); } return subject.stream; } @@ -51,10 +52,11 @@ class WebHelper { String url, String key, Map? authHeaders, + CancellationToken? cancellationToken, ) async { //Add to queue if there are too many calls. if (concurrentCalls >= fileFetcher.concurrentFetches) { - _queue.add(QueueItem(url, key, authHeaders)); + _queue.add(QueueItem(url, key, authHeaders, cancellationToken)); return; } cacheLogger.log( @@ -69,6 +71,7 @@ class WebHelper { url, key, authHeaders: authHeaders, + cancellationToken: cancellationToken, )) { subject.add(result); } @@ -85,7 +88,12 @@ class WebHelper { void _checkQueue() { if (_queue.isEmpty) return; final next = _queue.removeFirst(); - _downloadOrAddToQueue(next.url, next.key, next.headers); + _downloadOrAddToQueue( + next.url, + next.key, + next.headers, + next.cancellationToken, + ); } ///Download the file from the url @@ -93,6 +101,7 @@ class WebHelper { String url, String key, { Map? authHeaders, + CancellationToken? cancellationToken, }) async* { var cacheObject = await _store.retrieveCacheData(key); cacheObject = cacheObject == null @@ -103,13 +112,18 @@ class WebHelper { relativePath: '${const Uuid().v1()}.file', ) : cacheObject.copyWith(url: url); - final response = await _download(cacheObject, authHeaders); + final response = await _download( + cacheObject, + authHeaders, + cancellationToken, + ); yield* _manageResponse(cacheObject, response); } Future _download( CacheObject cacheObject, Map? authHeaders, + CancellationToken? cancellationToken, ) { final headers = {}; @@ -124,7 +138,11 @@ class WebHelper { headers.addAll(authHeaders); } - return fileFetcher.get(cacheObject.url, headers: headers); + return fileFetcher.get( + cacheObject.url, + headers: headers, + cancellationToken: cancellationToken, + ); } Stream _manageResponse( diff --git a/flutter_cache_manager/test/http_file_fetcher_test.dart b/flutter_cache_manager/test/http_file_fetcher_test.dart index 34f81d0b..aa60523f 100644 --- a/flutter_cache_manager/test/http_file_fetcher_test.dart +++ b/flutter_cache_manager/test/http_file_fetcher_test.dart @@ -117,4 +117,91 @@ void main() { }); }); }); + + group('Cancellation', () { + test('CancellationToken basic functionality', () { + final token = CancellationToken(); + expect(token.isCancelled, false); + + token.cancel(); + expect(token.isCancelled, true); + }); + + test('CancellationToken whenCancelled completes after cancel', () async { + final token = CancellationToken(); + final future = token.whenCancelled; + + token.cancel(); + await future; + expect(token.isCancelled, true); + }); + + test( + 'CancellationToken whenCancelled completes immediately if already cancelled', + () async { + final token = CancellationToken(); + token.cancel(); + + final future = token.whenCancelled; + await future; + expect(token.isCancelled, true); + }); + + test( + 'HttpFileService throws CancelledException when token is cancelled before request', + () async { + final token = CancellationToken(); + token.cancel(); + + final client = MockClient((request) async { + return Response.bytes(Uint8List(16), 200); + }); + + final httpFileFetcher = HttpFileService(httpClient: client); + + expect( + () => httpFileFetcher.get('test.com/image', cancellationToken: token), + throwsA(isA()), + ); + }); + + test( + 'HttpFileService throws CancelledException when token is cancelled after response', + () async { + final token = CancellationToken(); + + final client = MockClient((request) async { + return Response.bytes(Uint8List(16), 200); + }); + + final httpFileFetcher = HttpFileService(httpClient: client); + + // Start the request + final future = httpFileFetcher.get('http://test.com/image', + cancellationToken: token); + + // Cancel immediately after starting (before response completes) + token.cancel(); + + // Should throw CancelledException when response completes + expect( + future, + throwsA(isA()), + ); + }); + + test('HttpFileService works normally without cancellation token', () async { + final client = MockClient((request) async { + return Response.bytes(Uint8List(16), 200, headers: { + 'content-type': 'image/jpeg', + }); + }); + + final httpFileFetcher = HttpFileService(httpClient: client); + final response = await httpFileFetcher.get('test.com/image'); + + expect(response.statusCode, 200); + expect(response.contentLength, 16); + }); + }); } diff --git a/flutter_cache_manager_firebase/lib/src/firebase_http_file_service.dart b/flutter_cache_manager_firebase/lib/src/firebase_http_file_service.dart index 75d79ce9..d481826e 100644 --- a/flutter_cache_manager_firebase/lib/src/firebase_http_file_service.dart +++ b/flutter_cache_manager_firebase/lib/src/firebase_http_file_service.dart @@ -16,6 +16,7 @@ class FirebaseHttpFileService extends HttpFileService { Future get( String url, { Map? headers, + CancellationToken? cancellationToken, }) async { late Reference ref; if (bucket != null) { @@ -35,6 +36,10 @@ class FirebaseHttpFileService extends HttpFileService { } else { downloadUrl = await ref.getDownloadURL(); } - return super.get(downloadUrl); + return super.get( + downloadUrl, + headers: headers, + cancellationToken: cancellationToken, + ); } } From f49b176a3dbf6a444eb4e1fb43e8812e37b1461d Mon Sep 17 00:00:00 2001 From: Ben Milanko Date: Tue, 16 Dec 2025 18:05:10 +1100 Subject: [PATCH 14/20] Enhance image caching methods to support cancellation - Updated `ImageCacheManager` methods to accept `CancellationToken` for file fetching operations. - Ensured cancellation support is integrated into both direct file retrieval and resized image handling. --- .../lib/src/cache_managers/image_cache_manager.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/flutter_cache_manager/lib/src/cache_managers/image_cache_manager.dart b/flutter_cache_manager/lib/src/cache_managers/image_cache_manager.dart index e27b07c1..3c4c63ba 100644 --- a/flutter_cache_manager/lib/src/cache_managers/image_cache_manager.dart +++ b/flutter_cache_manager/lib/src/cache_managers/image_cache_manager.dart @@ -24,6 +24,7 @@ mixin ImageCacheManager on BaseCacheManager { bool withProgress = false, int? maxHeight, int? maxWidth, + CancellationToken? cancellationToken, }) async* { if (maxHeight == null && maxWidth == null) { yield* getFileStream( @@ -31,6 +32,7 @@ mixin ImageCacheManager on BaseCacheManager { key: key, headers: headers, withProgress: withProgress, + cancellationToken: cancellationToken, ); return; } @@ -56,6 +58,7 @@ mixin ImageCacheManager on BaseCacheManager { resizedKey, headers, withProgress, + cancellationToken: cancellationToken, maxWidth: maxWidth, maxHeight: maxHeight, ).asBroadcastStream(); @@ -130,12 +133,14 @@ mixin ImageCacheManager on BaseCacheManager { bool withProgress, { int? maxWidth, int? maxHeight, + CancellationToken? cancellationToken, }) async* { await for (final response in getFileStream( url, key: originalKey, headers: headers, withProgress: withProgress, + cancellationToken: cancellationToken, )) { if (response is DownloadProgress) { yield response; From e747265bb1f56398a39a701aa53e370a438ebeec Mon Sep 17 00:00:00 2001 From: Ben Milanko Date: Tue, 16 Dec 2025 18:09:11 +1100 Subject: [PATCH 15/20] Refactor HttpFileService to improve cancellation handling - Changed the constructor to ensure a default http.Client is used if none is provided. - Updated request handling to utilize AbortableRequest for better cancellation support. - Modified tests to reflect the new behavior, ensuring that cancellation after response completion does not throw an exception. --- .../lib/src/web/file_service.dart | 38 +++++++++---------- .../test/http_file_fetcher_test.dart | 14 +++---- 2 files changed, 22 insertions(+), 30 deletions(-) diff --git a/flutter_cache_manager/lib/src/web/file_service.dart b/flutter_cache_manager/lib/src/web/file_service.dart index f5503bf9..93e6da31 100644 --- a/flutter_cache_manager/lib/src/web/file_service.dart +++ b/flutter_cache_manager/lib/src/web/file_service.dart @@ -60,9 +60,10 @@ abstract class FileService { /// [HttpFileService] is the most common file service and the default for /// [WebHelper]. One can easily adapt it to use dio or any other http client. class HttpFileService extends FileService { - final http.Client? _httpClient; + final http.Client _httpClient; - HttpFileService({http.Client? httpClient}) : _httpClient = httpClient; + HttpFileService({http.Client? httpClient}) + : _httpClient = httpClient ?? http.Client(); @override Future get( @@ -74,31 +75,26 @@ class HttpFileService extends FileService { throw CancelledException(); } - final req = http.Request('GET', Uri.parse(url)); - if (headers != null) { - req.headers.addAll(headers); + final http.BaseRequest req; + if (cancellationToken != null) { + req = http.AbortableRequest( + 'GET', + Uri.parse(url), + abortTrigger: cancellationToken.whenCancelled, + ); + } else { + req = http.Request('GET', Uri.parse(url)); } - // Use dedicated client for cancellable requests, or shared client - final client = cancellationToken != null - ? http.Client() - : (_httpClient ?? http.Client()); - - if (cancellationToken != null) { - cancellationToken.whenCancelled.then((_) => client.close()); + if (headers != null) { + req.headers.addAll(headers); } try { - final httpResponse = await client.send(req); - if (cancellationToken?.isCancelled ?? false) { - throw CancelledException(); - } + final httpResponse = await _httpClient.send(req); return HttpGetResponse(httpResponse); - } catch (e) { - if (cancellationToken?.isCancelled ?? false) { - throw CancelledException(); - } - rethrow; + } on http.RequestAbortedException { + throw CancelledException(); } } } diff --git a/flutter_cache_manager/test/http_file_fetcher_test.dart b/flutter_cache_manager/test/http_file_fetcher_test.dart index aa60523f..8c205524 100644 --- a/flutter_cache_manager/test/http_file_fetcher_test.dart +++ b/flutter_cache_manager/test/http_file_fetcher_test.dart @@ -166,7 +166,7 @@ void main() { }); test( - 'HttpFileService throws CancelledException when token is cancelled after response', + 'HttpFileService completes normally if cancelled after response received', () async { final token = CancellationToken(); @@ -176,18 +176,14 @@ void main() { final httpFileFetcher = HttpFileService(httpClient: client); - // Start the request - final future = httpFileFetcher.get('http://test.com/image', + // Complete the request first + final response = await httpFileFetcher.get('http://test.com/image', cancellationToken: token); - // Cancel immediately after starting (before response completes) + // Cancelling after response is received has no effect token.cancel(); - // Should throw CancelledException when response completes - expect( - future, - throwsA(isA()), - ); + expect(response.statusCode, 200); }); test('HttpFileService works normally without cancellation token', () async { From 3c3125de3c1c4576acbb04264ae6b299caf9aeed Mon Sep 17 00:00:00 2001 From: Ben Milanko Date: Wed, 17 Dec 2025 03:25:53 +1100 Subject: [PATCH 16/20] Fix tests --- flutter_cache_manager/test/mock.mocks.dart | 191 +++++++++++---------- 1 file changed, 101 insertions(+), 90 deletions(-) diff --git a/flutter_cache_manager/test/mock.mocks.dart b/flutter_cache_manager/test/mock.mocks.dart index f966937e..defac177 100644 --- a/flutter_cache_manager/test/mock.mocks.dart +++ b/flutter_cache_manager/test/mock.mocks.dart @@ -3,16 +3,16 @@ // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i4; +import 'dart:async' as _i5; -import 'package:flutter_cache_manager/flutter_cache_manager.dart' as _i3; -import 'package:flutter_cache_manager/src/cache_store.dart' as _i5; +import 'package:flutter_cache_manager/flutter_cache_manager.dart' as _i4; +import 'package:flutter_cache_manager/src/cache_store.dart' as _i6; import 'package:flutter_cache_manager/src/storage/cache_object.dart' as _i2; import 'package:flutter_cache_manager/src/storage/file_system/file_system.dart' as _i3; -import 'package:flutter_cache_manager/src/web/web_helper.dart' as _i7; +import 'package:flutter_cache_manager/src/web/web_helper.dart' as _i8; import 'package:mockito/mockito.dart' as _i1; -import 'package:mockito/src/dummies.dart' as _i6; +import 'package:mockito/src/dummies.dart' as _i7; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values @@ -50,12 +50,12 @@ class _FakeDateTime_3 extends _i1.SmartFake implements DateTime { } class _FakeFileServiceResponse_4 extends _i1.SmartFake - implements _i3.FileServiceResponse { + implements _i4.FileServiceResponse { _FakeFileServiceResponse_4(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeFileService_5 extends _i1.SmartFake implements _i3.FileService { +class _FakeFileService_5 extends _i1.SmartFake implements _i4.FileService { _FakeFileService_5(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } @@ -64,37 +64,37 @@ class _FakeFileService_5 extends _i1.SmartFake implements _i3.FileService { /// /// See the documentation for Mockito's code generation for more information. class MockCacheInfoRepositoryBase extends _i1.Mock - implements _i3.CacheInfoRepository { + implements _i4.CacheInfoRepository { MockCacheInfoRepositoryBase() { _i1.throwOnMissingStub(this); } @override - _i4.Future exists() => + _i5.Future exists() => (super.noSuchMethod( Invocation.method(#exists, []), - returnValue: _i4.Future.value(false), + returnValue: _i5.Future.value(false), ) - as _i4.Future); + as _i5.Future); @override - _i4.Future open() => + _i5.Future open() => (super.noSuchMethod( Invocation.method(#open, []), - returnValue: _i4.Future.value(false), + returnValue: _i5.Future.value(false), ) - as _i4.Future); + as _i5.Future); @override - _i4.Future updateOrInsert(_i2.CacheObject? cacheObject) => + _i5.Future updateOrInsert(_i2.CacheObject? cacheObject) => (super.noSuchMethod( Invocation.method(#updateOrInsert, [cacheObject]), - returnValue: _i4.Future.value(), + returnValue: _i5.Future.value(), ) - as _i4.Future); + as _i5.Future); @override - _i4.Future<_i2.CacheObject> insert( + _i5.Future<_i2.CacheObject> insert( _i2.CacheObject? cacheObject, { bool? setTouchedToNow = true, }) => @@ -104,7 +104,7 @@ class MockCacheInfoRepositoryBase extends _i1.Mock [cacheObject], {#setTouchedToNow: setTouchedToNow}, ), - returnValue: _i4.Future<_i2.CacheObject>.value( + returnValue: _i5.Future<_i2.CacheObject>.value( _FakeCacheObject_0( this, Invocation.method( @@ -115,34 +115,34 @@ class MockCacheInfoRepositoryBase extends _i1.Mock ), ), ) - as _i4.Future<_i2.CacheObject>); + as _i5.Future<_i2.CacheObject>); @override - _i4.Future<_i2.CacheObject?> get(String? key) => + _i5.Future<_i2.CacheObject?> get(String? key) => (super.noSuchMethod( Invocation.method(#get, [key]), - returnValue: _i4.Future<_i2.CacheObject?>.value(), + returnValue: _i5.Future<_i2.CacheObject?>.value(), ) - as _i4.Future<_i2.CacheObject?>); + as _i5.Future<_i2.CacheObject?>); @override - _i4.Future delete(int? id) => + _i5.Future delete(int? id) => (super.noSuchMethod( Invocation.method(#delete, [id]), - returnValue: _i4.Future.value(0), + returnValue: _i5.Future.value(0), ) - as _i4.Future); + as _i5.Future); @override - _i4.Future deleteAll(Iterable? ids) => + _i5.Future deleteAll(Iterable? ids) => (super.noSuchMethod( Invocation.method(#deleteAll, [ids]), - returnValue: _i4.Future.value(0), + returnValue: _i5.Future.value(0), ) - as _i4.Future); + as _i5.Future); @override - _i4.Future update( + _i5.Future update( _i2.CacheObject? cacheObject, { bool? setTouchedToNow = true, }) => @@ -152,62 +152,62 @@ class MockCacheInfoRepositoryBase extends _i1.Mock [cacheObject], {#setTouchedToNow: setTouchedToNow}, ), - returnValue: _i4.Future.value(0), + returnValue: _i5.Future.value(0), ) - as _i4.Future); + as _i5.Future); @override - _i4.Future> getAllObjects() => + _i5.Future> getAllObjects() => (super.noSuchMethod( Invocation.method(#getAllObjects, []), - returnValue: _i4.Future>.value( + returnValue: _i5.Future>.value( <_i2.CacheObject>[], ), ) - as _i4.Future>); + as _i5.Future>); @override - _i4.Future> getObjectsOverCapacity(int? capacity) => + _i5.Future> getObjectsOverCapacity(int? capacity) => (super.noSuchMethod( Invocation.method(#getObjectsOverCapacity, [capacity]), - returnValue: _i4.Future>.value( + returnValue: _i5.Future>.value( <_i2.CacheObject>[], ), ) - as _i4.Future>); + as _i5.Future>); @override - _i4.Future> getOldObjects(Duration? maxAge) => + _i5.Future> getOldObjects(Duration? maxAge) => (super.noSuchMethod( Invocation.method(#getOldObjects, [maxAge]), - returnValue: _i4.Future>.value( + returnValue: _i5.Future>.value( <_i2.CacheObject>[], ), ) - as _i4.Future>); + as _i5.Future>); @override - _i4.Future close() => + _i5.Future close() => (super.noSuchMethod( Invocation.method(#close, []), - returnValue: _i4.Future.value(false), + returnValue: _i5.Future.value(false), ) - as _i4.Future); + as _i5.Future); @override - _i4.Future deleteDataFile() => + _i5.Future deleteDataFile() => (super.noSuchMethod( Invocation.method(#deleteDataFile, []), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), ) - as _i4.Future); + as _i5.Future); } /// A class which mocks [CacheStore]. /// /// See the documentation for Mockito's code generation for more information. -class MockCacheStoreBase extends _i1.Mock implements _i5.CacheStore { +class MockCacheStoreBase extends _i1.Mock implements _i6.CacheStore { MockCacheStoreBase() { _i1.throwOnMissingStub(this); } @@ -238,7 +238,7 @@ class MockCacheStoreBase extends _i1.Mock implements _i5.CacheStore { String get storeKey => (super.noSuchMethod( Invocation.getter(#storeKey), - returnValue: _i6.dummyValue( + returnValue: _i7.dummyValue( this, Invocation.getter(#storeKey), ), @@ -275,7 +275,7 @@ class MockCacheStoreBase extends _i1.Mock implements _i5.CacheStore { ); @override - _i4.Future<_i3.FileInfo?> getFile( + _i5.Future<_i4.FileInfo?> getFile( String? key, { bool? ignoreMemCache = false, }) => @@ -285,21 +285,21 @@ class MockCacheStoreBase extends _i1.Mock implements _i5.CacheStore { [key], {#ignoreMemCache: ignoreMemCache}, ), - returnValue: _i4.Future<_i3.FileInfo?>.value(), + returnValue: _i5.Future<_i4.FileInfo?>.value(), ) - as _i4.Future<_i3.FileInfo?>); + as _i5.Future<_i4.FileInfo?>); @override - _i4.Future putFile(_i2.CacheObject? cacheObject) => + _i5.Future putFile(_i2.CacheObject? cacheObject) => (super.noSuchMethod( Invocation.method(#putFile, [cacheObject]), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), ) - as _i4.Future); + as _i5.Future); @override - _i4.Future<_i2.CacheObject?> retrieveCacheData( + _i5.Future<_i2.CacheObject?> retrieveCacheData( String? key, { bool? ignoreMemCache = false, }) => @@ -309,26 +309,26 @@ class MockCacheStoreBase extends _i1.Mock implements _i5.CacheStore { [key], {#ignoreMemCache: ignoreMemCache}, ), - returnValue: _i4.Future<_i2.CacheObject?>.value(), + returnValue: _i5.Future<_i2.CacheObject?>.value(), ) - as _i4.Future<_i2.CacheObject?>); + as _i5.Future<_i2.CacheObject?>); @override - _i4.Future<_i3.FileInfo?> getFileFromMemory(String? key) => + _i5.Future<_i4.FileInfo?> getFileFromMemory(String? key) => (super.noSuchMethod( Invocation.method(#getFileFromMemory, [key]), - returnValue: _i4.Future<_i3.FileInfo?>.value(), + returnValue: _i5.Future<_i4.FileInfo?>.value(), ) - as _i4.Future<_i3.FileInfo?>); + as _i5.Future<_i4.FileInfo?>); @override - _i4.Future emptyCache() => + _i5.Future emptyCache() => (super.noSuchMethod( Invocation.method(#emptyCache, []), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), ) - as _i4.Future); + as _i5.Future); @override void emptyMemoryCache() => super.noSuchMethod( @@ -337,13 +337,13 @@ class MockCacheStoreBase extends _i1.Mock implements _i5.CacheStore { ); @override - _i4.Future removeCachedFile(_i2.CacheObject? cacheObject) => + _i5.Future removeCachedFile(_i2.CacheObject? cacheObject) => (super.noSuchMethod( Invocation.method(#removeCachedFile, [cacheObject]), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), ) - as _i4.Future); + as _i5.Future); @override bool memoryCacheContainsKey(String? key) => @@ -354,27 +354,27 @@ class MockCacheStoreBase extends _i1.Mock implements _i5.CacheStore { as bool); @override - _i4.Future dispose() => + _i5.Future dispose() => (super.noSuchMethod( Invocation.method(#dispose, []), - returnValue: _i4.Future.value(), - returnValueForMissingStub: _i4.Future.value(), + returnValue: _i5.Future.value(), + returnValueForMissingStub: _i5.Future.value(), ) - as _i4.Future); + as _i5.Future); @override - _i4.Future getCacheSize() => + _i5.Future getCacheSize() => (super.noSuchMethod( Invocation.method(#getCacheSize, []), - returnValue: _i4.Future.value(0), + returnValue: _i5.Future.value(0), ) - as _i4.Future); + as _i5.Future); } /// A class which mocks [FileService]. /// /// See the documentation for Mockito's code generation for more information. -class MockFileServiceBase extends _i1.Mock implements _i3.FileService { +class MockFileServiceBase extends _i1.Mock implements _i4.FileService { MockFileServiceBase() { _i1.throwOnMissingStub(this); } @@ -391,32 +391,41 @@ class MockFileServiceBase extends _i1.Mock implements _i3.FileService { ); @override - _i4.Future<_i3.FileServiceResponse> get( + _i5.Future<_i4.FileServiceResponse> get( String? url, { Map? headers, + _i4.CancellationToken? cancellationToken, }) => (super.noSuchMethod( - Invocation.method(#get, [url], {#headers: headers}), - returnValue: _i4.Future<_i3.FileServiceResponse>.value( + Invocation.method( + #get, + [url], + {#headers: headers, #cancellationToken: cancellationToken}, + ), + returnValue: _i5.Future<_i4.FileServiceResponse>.value( _FakeFileServiceResponse_4( this, - Invocation.method(#get, [url], {#headers: headers}), + Invocation.method( + #get, + [url], + {#headers: headers, #cancellationToken: cancellationToken}, + ), ), ), ) - as _i4.Future<_i3.FileServiceResponse>); + as _i5.Future<_i4.FileServiceResponse>); } /// A class which mocks [WebHelper]. /// /// See the documentation for Mockito's code generation for more information. -class MockWebHelper extends _i1.Mock implements _i7.WebHelper { +class MockWebHelper extends _i1.Mock implements _i8.WebHelper { MockWebHelper() { _i1.throwOnMissingStub(this); } @override - _i3.FileService get fileFetcher => + _i4.FileService get fileFetcher => (super.noSuchMethod( Invocation.getter(#fileFetcher), returnValue: _FakeFileService_5( @@ -424,7 +433,7 @@ class MockWebHelper extends _i1.Mock implements _i7.WebHelper { Invocation.getter(#fileFetcher), ), ) - as _i3.FileService); + as _i4.FileService); @override int get concurrentCalls => @@ -438,11 +447,12 @@ class MockWebHelper extends _i1.Mock implements _i7.WebHelper { ); @override - _i4.Stream<_i3.FileResponse> downloadFile( + _i5.Stream<_i4.FileResponse> downloadFile( String? url, { String? key, Map? authHeaders, bool? ignoreMemCache = false, + _i4.CancellationToken? cancellationToken, }) => (super.noSuchMethod( Invocation.method( @@ -452,9 +462,10 @@ class MockWebHelper extends _i1.Mock implements _i7.WebHelper { #key: key, #authHeaders: authHeaders, #ignoreMemCache: ignoreMemCache, + #cancellationToken: cancellationToken, }, ), - returnValue: _i4.Stream<_i3.FileResponse>.empty(), + returnValue: _i5.Stream<_i4.FileResponse>.empty(), ) - as _i4.Stream<_i3.FileResponse>); + as _i5.Stream<_i4.FileResponse>); } From 5f34e2a4376b2c28952c3ca23f9581c67fd693e5 Mon Sep 17 00:00:00 2001 From: Ben Milanko Date: Wed, 17 Dec 2025 21:14:06 +1100 Subject: [PATCH 17/20] Improve cancellation handling in file service and web helper - Added checks to prevent operations on already cancelled requests in `CancellationToken`. - Enhanced `WebHelper` to handle cancellation before starting downloads and during queue processing. - Ensured proper cleanup of subjects associated with cancelled requests to prevent memory leaks. --- .../lib/src/web/file_service.dart | 13 +++++++--- .../lib/src/web/web_helper.dart | 24 +++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/flutter_cache_manager/lib/src/web/file_service.dart b/flutter_cache_manager/lib/src/web/file_service.dart index 93e6da31..c5f73da4 100644 --- a/flutter_cache_manager/lib/src/web/file_service.dart +++ b/flutter_cache_manager/lib/src/web/file_service.dart @@ -19,14 +19,21 @@ class CancellationToken { /// Cancel the request associated with this token. void cancel() { + if (_isCancelled) return; _isCancelled = true; - _completer?.complete(); + if (_completer != null && !_completer!.isCompleted) { + _completer!.complete(); + } } /// A future that completes when this token is cancelled. Future get whenCancelled { - _completer ??= Completer(); - if (_isCancelled) _completer!.complete(); + if (_completer == null) { + _completer = Completer(); + if (_isCancelled) { + _completer!.complete(); + } + } return _completer!.future; } } diff --git a/flutter_cache_manager/lib/src/web/web_helper.dart b/flutter_cache_manager/lib/src/web/web_helper.dart index cebe59c4..a6e99e91 100644 --- a/flutter_cache_manager/lib/src/web/web_helper.dart +++ b/flutter_cache_manager/lib/src/web/web_helper.dart @@ -54,6 +54,17 @@ class WebHelper { Map? authHeaders, CancellationToken? cancellationToken, ) async { + // Check if already cancelled before starting + if (cancellationToken?.isCancelled ?? false) { + final subject = _memCache[key]; + if (subject != null) { + subject.addError(CancelledException()); + await subject.close(); + _memCache.remove(key); + } + return; + } + //Add to queue if there are too many calls. if (concurrentCalls >= fileFetcher.concurrentFetches) { _queue.add(QueueItem(url, key, authHeaders, cancellationToken)); @@ -88,6 +99,19 @@ class WebHelper { void _checkQueue() { if (_queue.isEmpty) return; final next = _queue.removeFirst(); + // Skip cancelled items + if (next.cancellationToken?.isCancelled ?? false) { + // Clean up the subject for this cancelled request + final subject = _memCache[next.key]; + if (subject != null) { + subject.addError(CancelledException()); + subject.close(); + _memCache.remove(next.key); + } + // Check the next item in queue + _checkQueue(); + return; + } _downloadOrAddToQueue( next.url, next.key, From dfcb6994dc16eb8e4604a35009b83320a32ba9a1 Mon Sep 17 00:00:00 2001 From: Ben Milanko Date: Wed, 17 Dec 2025 21:29:58 +1100 Subject: [PATCH 18/20] Enhance cancellation handling in WebHelper - Added multiple cancellation checks throughout the file update process in `WebHelper`. - Ensured that cancellation is checked before and after asynchronous operations, including downloads and file saves. - Updated `_manageResponse` and `_saveFile` methods to accept `CancellationToken` for improved cancellation support. --- .../lib/src/web/web_helper.dart | 68 ++++++++++++++++--- 1 file changed, 58 insertions(+), 10 deletions(-) diff --git a/flutter_cache_manager/lib/src/web/web_helper.dart b/flutter_cache_manager/lib/src/web/web_helper.dart index a6e99e91..5f0979dd 100644 --- a/flutter_cache_manager/lib/src/web/web_helper.dart +++ b/flutter_cache_manager/lib/src/web/web_helper.dart @@ -127,7 +127,18 @@ class WebHelper { Map? authHeaders, CancellationToken? cancellationToken, }) async* { + // Check cancellation before any work + if (cancellationToken?.isCancelled ?? false) { + throw CancelledException(); + } + var cacheObject = await _store.retrieveCacheData(key); + + // Check cancellation after async work + if (cancellationToken?.isCancelled ?? false) { + throw CancelledException(); + } + cacheObject = cacheObject == null ? CacheObject( url, @@ -141,7 +152,13 @@ class WebHelper { authHeaders, cancellationToken, ); - yield* _manageResponse(cacheObject, response); + + // Check cancellation after download + if (cancellationToken?.isCancelled ?? false) { + throw CancelledException(); + } + + yield* _manageResponse(cacheObject, response, cancellationToken); } Future _download( @@ -172,7 +189,13 @@ class WebHelper { Stream _manageResponse( CacheObject cacheObject, FileServiceResponse response, + CancellationToken? cancellationToken, ) async* { + // Check cancellation at start + if (cancellationToken?.isCancelled ?? false) { + throw CancelledException(); + } + final hasNewFile = statusCodesNewFile.contains(response.statusCode); final keepOldFile = statusCodesFileNotChanged.contains(response.statusCode); if (!hasNewFile && !keepOldFile) { @@ -187,7 +210,15 @@ class WebHelper { var newCacheObject = _setDataFromHeaders(cacheObject, response); if (statusCodesNewFile.contains(response.statusCode)) { var savedBytes = 0; - await for (final progress in _saveFile(newCacheObject, response)) { + await for (final progress in _saveFile( + newCacheObject, + response, + cancellationToken, + )) { + // Check cancellation during file save + if (cancellationToken?.isCancelled ?? false) { + throw CancelledException(); + } savedBytes = progress; yield DownloadProgress( cacheObject.url, @@ -198,6 +229,11 @@ class WebHelper { newCacheObject = newCacheObject.copyWith(length: savedBytes); } + // Check cancellation before storing + if (cancellationToken?.isCancelled ?? false) { + throw CancelledException(); + } + _store.putFile(newCacheObject).then((_) { if (newCacheObject.relativePath != oldCacheObject.relativePath) { _removeOldFile(oldCacheObject.relativePath); @@ -238,12 +274,17 @@ class WebHelper { ); } - Stream _saveFile(CacheObject cacheObject, FileServiceResponse response) { + Stream _saveFile( + CacheObject cacheObject, + FileServiceResponse response, + CancellationToken? cancellationToken, + ) { final receivedBytesResultController = StreamController(); _saveFileAndPostUpdates( receivedBytesResultController, cacheObject, response, + cancellationToken, ); return receivedBytesResultController.stream; } @@ -252,19 +293,26 @@ class WebHelper { StreamController receivedBytesResultController, CacheObject cacheObject, FileServiceResponse response, + CancellationToken? cancellationToken, ) async { final file = await _store.fileSystem.createFile(cacheObject.relativePath); try { var receivedBytes = 0; final sink = file.openWrite(); - await response.content - .map((s) { - receivedBytes += s.length; - receivedBytesResultController.add(receivedBytes); - return s; - }) - .pipe(sink); + await for (final chunk in response.content) { + // Check cancellation while receiving data + if (cancellationToken?.isCancelled ?? false) { + await sink.close(); + receivedBytesResultController.addError(CancelledException()); + await receivedBytesResultController.close(); + return; + } + receivedBytes += chunk.length; + receivedBytesResultController.add(receivedBytes); + sink.add(chunk); + } + await sink.close(); } on Object catch (e, stacktrace) { receivedBytesResultController.addError(e, stacktrace); } From 6ab6a1075985731321620ec7ddc515aaa143992c Mon Sep 17 00:00:00 2001 From: Ben Milanko Date: Sat, 5 Sep 2026 13:59:29 +1000 Subject: [PATCH 19/20] Regenerate mocks and format cancellation sources Rebuilds test/mock.mocks.dart against mockito 5.8.1 and applies dart format to the cancellation changes so CI's format check passes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TCmKSRuHNETmfGu2TffVzo --- .../lib/src/web/file_service.dart | 2 +- .../test/http_file_fetcher_test.dart | 83 ++++---- flutter_cache_manager/test/mock.mocks.dart | 185 +++++++++--------- 3 files changed, 139 insertions(+), 131 deletions(-) diff --git a/flutter_cache_manager/lib/src/web/file_service.dart b/flutter_cache_manager/lib/src/web/file_service.dart index c5f73da4..11c3f174 100644 --- a/flutter_cache_manager/lib/src/web/file_service.dart +++ b/flutter_cache_manager/lib/src/web/file_service.dart @@ -70,7 +70,7 @@ class HttpFileService extends FileService { final http.Client _httpClient; HttpFileService({http.Client? httpClient}) - : _httpClient = httpClient ?? http.Client(); + : _httpClient = httpClient ?? http.Client(); @override Future get( diff --git a/flutter_cache_manager/test/http_file_fetcher_test.dart b/flutter_cache_manager/test/http_file_fetcher_test.dart index 8c205524..d6cbe0cf 100644 --- a/flutter_cache_manager/test/http_file_fetcher_test.dart +++ b/flutter_cache_manager/test/http_file_fetcher_test.dart @@ -137,60 +137,67 @@ void main() { }); test( - 'CancellationToken whenCancelled completes immediately if already cancelled', - () async { - final token = CancellationToken(); - token.cancel(); + 'CancellationToken whenCancelled completes immediately if already cancelled', + () async { + final token = CancellationToken(); + token.cancel(); - final future = token.whenCancelled; - await future; - expect(token.isCancelled, true); - }); + final future = token.whenCancelled; + await future; + expect(token.isCancelled, true); + }, + ); test( - 'HttpFileService throws CancelledException when token is cancelled before request', - () async { - final token = CancellationToken(); - token.cancel(); + 'HttpFileService throws CancelledException when token is cancelled before request', + () async { + final token = CancellationToken(); + token.cancel(); - final client = MockClient((request) async { - return Response.bytes(Uint8List(16), 200); - }); + final client = MockClient((request) async { + return Response.bytes(Uint8List(16), 200); + }); - final httpFileFetcher = HttpFileService(httpClient: client); + final httpFileFetcher = HttpFileService(httpClient: client); - expect( - () => httpFileFetcher.get('test.com/image', cancellationToken: token), - throwsA(isA()), - ); - }); + expect( + () => httpFileFetcher.get('test.com/image', cancellationToken: token), + throwsA(isA()), + ); + }, + ); test( - 'HttpFileService completes normally if cancelled after response received', - () async { - final token = CancellationToken(); + 'HttpFileService completes normally if cancelled after response received', + () async { + final token = CancellationToken(); - final client = MockClient((request) async { - return Response.bytes(Uint8List(16), 200); - }); + final client = MockClient((request) async { + return Response.bytes(Uint8List(16), 200); + }); - final httpFileFetcher = HttpFileService(httpClient: client); + final httpFileFetcher = HttpFileService(httpClient: client); - // Complete the request first - final response = await httpFileFetcher.get('http://test.com/image', - cancellationToken: token); + // Complete the request first + final response = await httpFileFetcher.get( + 'http://test.com/image', + cancellationToken: token, + ); - // Cancelling after response is received has no effect - token.cancel(); + // Cancelling after response is received has no effect + token.cancel(); - expect(response.statusCode, 200); - }); + expect(response.statusCode, 200); + }, + ); test('HttpFileService works normally without cancellation token', () async { final client = MockClient((request) async { - return Response.bytes(Uint8List(16), 200, headers: { - 'content-type': 'image/jpeg', - }); + return Response.bytes( + Uint8List(16), + 200, + headers: {'content-type': 'image/jpeg'}, + ); }); final httpFileFetcher = HttpFileService(httpClient: client); diff --git a/flutter_cache_manager/test/mock.mocks.dart b/flutter_cache_manager/test/mock.mocks.dart index defac177..e9832caf 100644 --- a/flutter_cache_manager/test/mock.mocks.dart +++ b/flutter_cache_manager/test/mock.mocks.dart @@ -1,18 +1,18 @@ -// Mocks generated by Mockito 5.4.6 from annotations +// Mocks generated by Mockito from annotations // in flutter_cache_manager/test/mock.dart. // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i5; -import 'package:flutter_cache_manager/flutter_cache_manager.dart' as _i4; -import 'package:flutter_cache_manager/src/cache_store.dart' as _i6; -import 'package:flutter_cache_manager/src/storage/cache_object.dart' as _i2; +import 'dart:async' as _i4; + +import 'package:flutter_cache_manager/flutter_cache_manager.dart' as _i2; +import 'package:flutter_cache_manager/src/cache_store.dart' as _i5; import 'package:flutter_cache_manager/src/storage/file_system/file_system.dart' as _i3; -import 'package:flutter_cache_manager/src/web/web_helper.dart' as _i8; +import 'package:flutter_cache_manager/src/web/web_helper.dart' as _i7; import 'package:mockito/mockito.dart' as _i1; -import 'package:mockito/src/dummies.dart' as _i7; +import 'package:mockito/src/dummies.dart' as _i6; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values @@ -20,6 +20,7 @@ import 'package:mockito/src/dummies.dart' as _i7; // ignore_for_file: comment_references // ignore_for_file: deprecated_member_use // ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: experimental_member_use // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member // ignore_for_file: must_be_immutable @@ -50,12 +51,12 @@ class _FakeDateTime_3 extends _i1.SmartFake implements DateTime { } class _FakeFileServiceResponse_4 extends _i1.SmartFake - implements _i4.FileServiceResponse { + implements _i2.FileServiceResponse { _FakeFileServiceResponse_4(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } -class _FakeFileService_5 extends _i1.SmartFake implements _i4.FileService { +class _FakeFileService_5 extends _i1.SmartFake implements _i2.FileService { _FakeFileService_5(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } @@ -64,37 +65,37 @@ class _FakeFileService_5 extends _i1.SmartFake implements _i4.FileService { /// /// See the documentation for Mockito's code generation for more information. class MockCacheInfoRepositoryBase extends _i1.Mock - implements _i4.CacheInfoRepository { + implements _i2.CacheInfoRepository { MockCacheInfoRepositoryBase() { _i1.throwOnMissingStub(this); } @override - _i5.Future exists() => + _i4.Future exists() => (super.noSuchMethod( Invocation.method(#exists, []), - returnValue: _i5.Future.value(false), + returnValue: _i4.Future.value(false), ) - as _i5.Future); + as _i4.Future); @override - _i5.Future open() => + _i4.Future open() => (super.noSuchMethod( Invocation.method(#open, []), - returnValue: _i5.Future.value(false), + returnValue: _i4.Future.value(false), ) - as _i5.Future); + as _i4.Future); @override - _i5.Future updateOrInsert(_i2.CacheObject? cacheObject) => + _i4.Future updateOrInsert(_i2.CacheObject? cacheObject) => (super.noSuchMethod( Invocation.method(#updateOrInsert, [cacheObject]), - returnValue: _i5.Future.value(), + returnValue: _i4.Future.value(), ) - as _i5.Future); + as _i4.Future); @override - _i5.Future<_i2.CacheObject> insert( + _i4.Future<_i2.CacheObject> insert( _i2.CacheObject? cacheObject, { bool? setTouchedToNow = true, }) => @@ -104,7 +105,7 @@ class MockCacheInfoRepositoryBase extends _i1.Mock [cacheObject], {#setTouchedToNow: setTouchedToNow}, ), - returnValue: _i5.Future<_i2.CacheObject>.value( + returnValue: _i4.Future<_i2.CacheObject>.value( _FakeCacheObject_0( this, Invocation.method( @@ -115,34 +116,34 @@ class MockCacheInfoRepositoryBase extends _i1.Mock ), ), ) - as _i5.Future<_i2.CacheObject>); + as _i4.Future<_i2.CacheObject>); @override - _i5.Future<_i2.CacheObject?> get(String? key) => + _i4.Future<_i2.CacheObject?> get(String? key) => (super.noSuchMethod( Invocation.method(#get, [key]), - returnValue: _i5.Future<_i2.CacheObject?>.value(), + returnValue: _i4.Future<_i2.CacheObject?>.value(), ) - as _i5.Future<_i2.CacheObject?>); + as _i4.Future<_i2.CacheObject?>); @override - _i5.Future delete(int? id) => + _i4.Future delete(int? id) => (super.noSuchMethod( Invocation.method(#delete, [id]), - returnValue: _i5.Future.value(0), + returnValue: _i4.Future.value(0), ) - as _i5.Future); + as _i4.Future); @override - _i5.Future deleteAll(Iterable? ids) => + _i4.Future deleteAll(Iterable? ids) => (super.noSuchMethod( Invocation.method(#deleteAll, [ids]), - returnValue: _i5.Future.value(0), + returnValue: _i4.Future.value(0), ) - as _i5.Future); + as _i4.Future); @override - _i5.Future update( + _i4.Future update( _i2.CacheObject? cacheObject, { bool? setTouchedToNow = true, }) => @@ -152,62 +153,62 @@ class MockCacheInfoRepositoryBase extends _i1.Mock [cacheObject], {#setTouchedToNow: setTouchedToNow}, ), - returnValue: _i5.Future.value(0), + returnValue: _i4.Future.value(0), ) - as _i5.Future); + as _i4.Future); @override - _i5.Future> getAllObjects() => + _i4.Future> getAllObjects() => (super.noSuchMethod( Invocation.method(#getAllObjects, []), - returnValue: _i5.Future>.value( + returnValue: _i4.Future>.value( <_i2.CacheObject>[], ), ) - as _i5.Future>); + as _i4.Future>); @override - _i5.Future> getObjectsOverCapacity(int? capacity) => + _i4.Future> getObjectsOverCapacity(int? capacity) => (super.noSuchMethod( Invocation.method(#getObjectsOverCapacity, [capacity]), - returnValue: _i5.Future>.value( + returnValue: _i4.Future>.value( <_i2.CacheObject>[], ), ) - as _i5.Future>); + as _i4.Future>); @override - _i5.Future> getOldObjects(Duration? maxAge) => + _i4.Future> getOldObjects(Duration? maxAge) => (super.noSuchMethod( Invocation.method(#getOldObjects, [maxAge]), - returnValue: _i5.Future>.value( + returnValue: _i4.Future>.value( <_i2.CacheObject>[], ), ) - as _i5.Future>); + as _i4.Future>); @override - _i5.Future close() => + _i4.Future close() => (super.noSuchMethod( Invocation.method(#close, []), - returnValue: _i5.Future.value(false), + returnValue: _i4.Future.value(false), ) - as _i5.Future); + as _i4.Future); @override - _i5.Future deleteDataFile() => + _i4.Future deleteDataFile() => (super.noSuchMethod( Invocation.method(#deleteDataFile, []), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), ) - as _i5.Future); + as _i4.Future); } /// A class which mocks [CacheStore]. /// /// See the documentation for Mockito's code generation for more information. -class MockCacheStoreBase extends _i1.Mock implements _i6.CacheStore { +class MockCacheStoreBase extends _i1.Mock implements _i5.CacheStore { MockCacheStoreBase() { _i1.throwOnMissingStub(this); } @@ -238,7 +239,7 @@ class MockCacheStoreBase extends _i1.Mock implements _i6.CacheStore { String get storeKey => (super.noSuchMethod( Invocation.getter(#storeKey), - returnValue: _i7.dummyValue( + returnValue: _i6.dummyValue( this, Invocation.getter(#storeKey), ), @@ -275,7 +276,7 @@ class MockCacheStoreBase extends _i1.Mock implements _i6.CacheStore { ); @override - _i5.Future<_i4.FileInfo?> getFile( + _i4.Future<_i2.FileInfo?> getFile( String? key, { bool? ignoreMemCache = false, }) => @@ -285,21 +286,21 @@ class MockCacheStoreBase extends _i1.Mock implements _i6.CacheStore { [key], {#ignoreMemCache: ignoreMemCache}, ), - returnValue: _i5.Future<_i4.FileInfo?>.value(), + returnValue: _i4.Future<_i2.FileInfo?>.value(), ) - as _i5.Future<_i4.FileInfo?>); + as _i4.Future<_i2.FileInfo?>); @override - _i5.Future putFile(_i2.CacheObject? cacheObject) => + _i4.Future putFile(_i2.CacheObject? cacheObject) => (super.noSuchMethod( Invocation.method(#putFile, [cacheObject]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), ) - as _i5.Future); + as _i4.Future); @override - _i5.Future<_i2.CacheObject?> retrieveCacheData( + _i4.Future<_i2.CacheObject?> retrieveCacheData( String? key, { bool? ignoreMemCache = false, }) => @@ -309,26 +310,26 @@ class MockCacheStoreBase extends _i1.Mock implements _i6.CacheStore { [key], {#ignoreMemCache: ignoreMemCache}, ), - returnValue: _i5.Future<_i2.CacheObject?>.value(), + returnValue: _i4.Future<_i2.CacheObject?>.value(), ) - as _i5.Future<_i2.CacheObject?>); + as _i4.Future<_i2.CacheObject?>); @override - _i5.Future<_i4.FileInfo?> getFileFromMemory(String? key) => + _i4.Future<_i2.FileInfo?> getFileFromMemory(String? key) => (super.noSuchMethod( Invocation.method(#getFileFromMemory, [key]), - returnValue: _i5.Future<_i4.FileInfo?>.value(), + returnValue: _i4.Future<_i2.FileInfo?>.value(), ) - as _i5.Future<_i4.FileInfo?>); + as _i4.Future<_i2.FileInfo?>); @override - _i5.Future emptyCache() => + _i4.Future emptyCache() => (super.noSuchMethod( Invocation.method(#emptyCache, []), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), ) - as _i5.Future); + as _i4.Future); @override void emptyMemoryCache() => super.noSuchMethod( @@ -337,13 +338,13 @@ class MockCacheStoreBase extends _i1.Mock implements _i6.CacheStore { ); @override - _i5.Future removeCachedFile(_i2.CacheObject? cacheObject) => + _i4.Future removeCachedFile(_i2.CacheObject? cacheObject) => (super.noSuchMethod( Invocation.method(#removeCachedFile, [cacheObject]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), ) - as _i5.Future); + as _i4.Future); @override bool memoryCacheContainsKey(String? key) => @@ -354,27 +355,27 @@ class MockCacheStoreBase extends _i1.Mock implements _i6.CacheStore { as bool); @override - _i5.Future dispose() => + _i4.Future dispose() => (super.noSuchMethod( Invocation.method(#dispose, []), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), ) - as _i5.Future); + as _i4.Future); @override - _i5.Future getCacheSize() => + _i4.Future getCacheSize() => (super.noSuchMethod( Invocation.method(#getCacheSize, []), - returnValue: _i5.Future.value(0), + returnValue: _i4.Future.value(0), ) - as _i5.Future); + as _i4.Future); } /// A class which mocks [FileService]. /// /// See the documentation for Mockito's code generation for more information. -class MockFileServiceBase extends _i1.Mock implements _i4.FileService { +class MockFileServiceBase extends _i1.Mock implements _i2.FileService { MockFileServiceBase() { _i1.throwOnMissingStub(this); } @@ -391,10 +392,10 @@ class MockFileServiceBase extends _i1.Mock implements _i4.FileService { ); @override - _i5.Future<_i4.FileServiceResponse> get( + _i4.Future<_i2.FileServiceResponse> get( String? url, { Map? headers, - _i4.CancellationToken? cancellationToken, + _i2.CancellationToken? cancellationToken, }) => (super.noSuchMethod( Invocation.method( @@ -402,7 +403,7 @@ class MockFileServiceBase extends _i1.Mock implements _i4.FileService { [url], {#headers: headers, #cancellationToken: cancellationToken}, ), - returnValue: _i5.Future<_i4.FileServiceResponse>.value( + returnValue: _i4.Future<_i2.FileServiceResponse>.value( _FakeFileServiceResponse_4( this, Invocation.method( @@ -413,19 +414,19 @@ class MockFileServiceBase extends _i1.Mock implements _i4.FileService { ), ), ) - as _i5.Future<_i4.FileServiceResponse>); + as _i4.Future<_i2.FileServiceResponse>); } /// A class which mocks [WebHelper]. /// /// See the documentation for Mockito's code generation for more information. -class MockWebHelper extends _i1.Mock implements _i8.WebHelper { +class MockWebHelper extends _i1.Mock implements _i7.WebHelper { MockWebHelper() { _i1.throwOnMissingStub(this); } @override - _i4.FileService get fileFetcher => + _i2.FileService get fileFetcher => (super.noSuchMethod( Invocation.getter(#fileFetcher), returnValue: _FakeFileService_5( @@ -433,7 +434,7 @@ class MockWebHelper extends _i1.Mock implements _i8.WebHelper { Invocation.getter(#fileFetcher), ), ) - as _i4.FileService); + as _i2.FileService); @override int get concurrentCalls => @@ -447,12 +448,12 @@ class MockWebHelper extends _i1.Mock implements _i8.WebHelper { ); @override - _i5.Stream<_i4.FileResponse> downloadFile( + _i4.Stream<_i2.FileResponse> downloadFile( String? url, { String? key, Map? authHeaders, bool? ignoreMemCache = false, - _i4.CancellationToken? cancellationToken, + _i2.CancellationToken? cancellationToken, }) => (super.noSuchMethod( Invocation.method( @@ -465,7 +466,7 @@ class MockWebHelper extends _i1.Mock implements _i8.WebHelper { #cancellationToken: cancellationToken, }, ), - returnValue: _i5.Stream<_i4.FileResponse>.empty(), + returnValue: _i4.Stream<_i2.FileResponse>.empty(), ) - as _i5.Stream<_i4.FileResponse>); + as _i4.Stream<_i2.FileResponse>); } From 71826678b86cdc04fd5d17b362926893592b8bf0 Mon Sep 17 00:00:00 2001 From: Ben Milanko Date: Sat, 5 Sep 2026 14:21:30 +1000 Subject: [PATCH 20/20] Resolve flutter_cache_manager from source in the firebase package flutter_cache_manager_firebase depends on flutter_cache_manager ^3.4.2 from pub.dev, which has no CancellationToken. FirebaseHttpFileService overrides HttpFileService.get, so once that signature gained a cancellationToken parameter the package stopped analyzing: error - Undefined class 'CancellationToken' error - The named parameter 'cancellationToken' isn't defined Override the dependency with the sibling package in this repository, so the two move together. Overrides are ignored by consumers, so this does not affect anyone depending on the published package. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TCmKSRuHNETmfGu2TffVzo --- flutter_cache_manager_firebase/pubspec.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/flutter_cache_manager_firebase/pubspec.yaml b/flutter_cache_manager_firebase/pubspec.yaml index f9d02d16..b1af1c25 100644 --- a/flutter_cache_manager_firebase/pubspec.yaml +++ b/flutter_cache_manager_firebase/pubspec.yaml @@ -19,3 +19,10 @@ dev_dependencies: flutter_lints: ^6.0.0 flutter_test: sdk: flutter + +# This package tracks flutter_cache_manager in the same repository. The +# cancellation API it implements is not on pub.dev yet, so resolve the sibling +# package from source. Overrides are ignored by consumers of this package. +dependency_overrides: + flutter_cache_manager: + path: ../flutter_cache_manager