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/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/example/lib/main.dart b/flutter_cache_manager/example/lib/main.dart index ee34ca00..a85ff169 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..c06a3349 --- /dev/null +++ b/flutter_cache_manager/example/lib/test_indexeddb.dart @@ -0,0 +1,223 @@ +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 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/lib/flutter_cache_manager.dart b/flutter_cache_manager/lib/flutter_cache_manager.dart index 05064282..e83b9d20 100644 --- a/flutter_cache_manager/lib/flutter_cache_manager.dart +++ b/flutter_cache_manager/lib/flutter_cache_manager.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_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/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; diff --git a/flutter_cache_manager/lib/src/cache_store.dart b/flutter_cache_manager/lib/src/cache_store.dart index 44ffbfeb..d03cbccf 100644 --- a/flutter_cache_manager/lib/src/cache_store.dart +++ b/flutter_cache_manager/lib/src/cache_store.dart @@ -1,8 +1,11 @@ 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'; + ///Flutter Cache Manager ///Copyright (c) 2019 Rene Floor ///Released under MIT License. @@ -197,7 +200,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 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/config/_config_io.dart b/flutter_cache_manager/lib/src/config/_config_io.dart index 8df79baf..5d69a58b 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 9a019781..ef8b62fa 100644 --- a/flutter_cache_manager/lib/src/config/_config_web.dart +++ b/flutter_cache_manager/lib/src/config/_config_web.dart @@ -1,8 +1,8 @@ +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/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/web/file_service.dart'; +import 'package:flutter_cache_manager/src/storage/file_system/indexed_db_file_system.dart'; class Config implements def.Config { Config( @@ -14,8 +14,13 @@ 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 88dabb37..98597a70 100644 --- a/flutter_cache_manager/lib/src/config/config.dart +++ b/flutter_cache_manager/lib/src/config/config.dart @@ -4,6 +4,8 @@ import 'package:flutter_cache_manager/src/config/_config_unsupported.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..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 @@ -2,3 +2,7 @@ export 'cache_info_repository.dart'; export 'cache_object_provider.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/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..d7349910 --- /dev/null +++ b/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_cache_info_repository.dart @@ -0,0 +1,636 @@ +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'; +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 String _touchedIndexName = 'touched_index'; + static const int _dbVersion = 2; // Incremented for new index + + late final IndexedDbConnectionPool _connectionPool; + + Future _getDatabase() async { + return _connectionPool.getDatabase(); + } + + void _initConnectionPool() { + _connectionPool = IndexedDbConnectionPool.getInstance( + databaseName: databaseName, + version: _dbVersion, + onUpgrade: _onUpgradeNeeded, + ); + } + + void _onUpgradeNeeded(web.IDBDatabase db, web.IDBVersionChangeEvent e) { + final oldVersion = e.oldVersion; + + // 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( + _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), + ); + // 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 + // 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), + ); + } + } + + // 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 + Future open() async { + 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); + } + } + + /// 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(); + final completer = Completer(); + + final transaction = _createTransaction(db, _metadataStoreName, '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 = _createTransaction(db, _metadataStoreName, '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."); + } + + 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(); + + final transaction = _createTransaction(db, _metadataStoreName, '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; + + 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 + 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 = _createTransaction(db, _metadataStoreName, '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 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 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 + Future delete(int id) async { + final db = await _getDatabase(); + final completer = Completer(); + + final transaction = _createTransaction(db, _metadataStoreName, '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(); + + 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)); + } + + // Wait for the entire transaction to complete + // This ensures all deletes are atomic + transaction.oncomplete = (web.Event e) { + completer.complete(ids.length); + }.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 await completer.future; + } catch (e) { + throw Exception('Failed to delete all objects: $e'); + } + } + + @override + Future close() async { + if (!shouldClose()) { + return false; + } + // 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); + + request.onsuccess = (web.Event e) { + completer.complete(); + }.toJS; + + request.onerror = (web.Event e) { + completer.completeError( + Exception('Failed to delete IndexedDB database: ${request.error}'), + ); + }.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; + } + + @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 JSString; + final key = keyJs.toDart; + final value = obj[keyJs]; + + if (value == null) { + map[key] = null; + } else if (value.typeofEquals('string')) { + 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[key] = num.toInt(); + } else { + map[key] = num; + } + } else if (value.typeofEquals('boolean')) { + map[key] = (value as JSBoolean).toDart; + } else { + map[key] = 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/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..88506388 --- /dev/null +++ b/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_connection_pool.dart @@ -0,0 +1,134 @@ +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/file_system.dart b/flutter_cache_manager/lib/src/storage/file_system/file_system.dart index 2cd9764b..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,9 +1,8 @@ -export 'file_system.dart'; -export 'file_system_io.dart'; -export 'file_system_web.dart'; - import 'package:file/file.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..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,6 +2,12 @@ import 'package:file/file.dart' show File; import 'package:file/memory.dart'; import 'package:flutter_cache_manager/src/storage/file_system/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.dart b/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file.dart new file mode 100644 index 00000000..f6551e38 --- /dev/null +++ b/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file.dart @@ -0,0 +1,553 @@ +import 'dart:async'; +import 'dart:convert'; +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; + +/// 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 = 2; // Incremented to match repository version + + late final IndexedDbConnectionPool _connectionPool = + IndexedDbConnectionPool.getInstance( + databaseName: _dbName, + version: _dbVersion, + onUpgrade: _onUpgradeNeeded, + ); + + void _onUpgradeNeeded(web.IDBDatabase db, web.IDBVersionChangeEvent e) { + final oldVersion = e.oldVersion; + + // 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), + ); + } + + // 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( + 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), + ); + // Create index on touched field for efficient sorting + metadataStore.createIndex( + touchedIndexName, + 'touched'.toJS, + web.IDBIndexParameters(unique: false), + ); + } + } + + // 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), + ); + } + } + } + } + + Future _getDatabase() async { + return _connectionPool.getDatabase(); + } + + /// 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 _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.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 completer.future; + } + + @override + 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'); + 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; + } + + @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 _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 + bool existsSync() { + throw UnsupportedError('existsSync is not supported on web'); + } + + @override + Future delete({bool recursive = false}) async { + 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.onerror = (web.Event e) { + completer.completeError( + Exception('Failed to delete file from IndexedDB: ${request.error}'), + ); + }.toJS; + + await completer.future; + return this; + } + + @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_stub.dart b/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file_stub.dart new file mode 100644 index 00000000..1c6751cb --- /dev/null +++ b/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file_stub.dart @@ -0,0 +1,183 @@ +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.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/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'); + } +} diff --git a/flutter_cache_manager/lib/src/web/file_service.dart b/flutter_cache_manager/lib/src/web/file_service.dart index 376d672e..11c3f174 100644 --- a/flutter_cache_manager/lib/src/web/file_service.dart +++ b/flutter_cache_manager/lib/src/web/file_service.dart @@ -9,6 +9,47 @@ 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() { + if (_isCancelled) return; + _isCancelled = true; + if (_completer != null && !_completer!.isCompleted) { + _completer!.complete(); + } + } + + /// A future that completes when this token is cancelled. + Future get whenCancelled { + if (_completer == null) { + _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,7 +57,11 @@ 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 @@ -31,14 +76,33 @@ class HttpFileService extends FileService { Future get( String url, { Map? headers, + CancellationToken? cancellationToken, }) async { - final req = http.Request('GET', Uri.parse(url)); + if (cancellationToken?.isCancelled ?? false) { + throw CancelledException(); + } + + 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)); + } + if (headers != null) { req.headers.addAll(headers); } - final httpResponse = await _httpClient.send(req); - return HttpGetResponse(httpResponse); + try { + final httpResponse = await _httpClient.send(req); + return HttpGetResponse(httpResponse); + } on http.RequestAbortedException { + throw CancelledException(); + } } } 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 126a2c8f..5f0979dd 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,22 @@ class WebHelper { String url, String key, 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)); + _queue.add(QueueItem(url, key, authHeaders, cancellationToken)); return; } cacheLogger.log( @@ -69,6 +82,7 @@ class WebHelper { url, key, authHeaders: authHeaders, + cancellationToken: cancellationToken, )) { subject.add(result); } @@ -85,7 +99,25 @@ class WebHelper { void _checkQueue() { if (_queue.isEmpty) return; final next = _queue.removeFirst(); - _downloadOrAddToQueue(next.url, next.key, next.headers); + // 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, + next.headers, + next.cancellationToken, + ); } ///Download the file from the url @@ -93,8 +125,20 @@ class WebHelper { String url, String key, { 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, @@ -103,13 +147,24 @@ class WebHelper { relativePath: '${const Uuid().v1()}.file', ) : cacheObject.copyWith(url: url); - final response = await _download(cacheObject, authHeaders); - yield* _manageResponse(cacheObject, response); + final response = await _download( + cacheObject, + authHeaders, + cancellationToken, + ); + + // Check cancellation after download + if (cancellationToken?.isCancelled ?? false) { + throw CancelledException(); + } + + yield* _manageResponse(cacheObject, response, cancellationToken); } Future _download( CacheObject cacheObject, Map? authHeaders, + CancellationToken? cancellationToken, ) { final headers = {}; @@ -124,13 +179,23 @@ class WebHelper { headers.addAll(authHeaders); } - return fileFetcher.get(cacheObject.url, headers: headers); + return fileFetcher.get( + cacheObject.url, + headers: headers, + cancellationToken: cancellationToken, + ); } 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) { @@ -145,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, @@ -156,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); @@ -196,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; } @@ -210,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); } @@ -239,8 +329,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/pubspec.yaml b/flutter_cache_manager/pubspec.yaml index 39b3c4f2..9845dee3 100644 --- a/flutter_cache_manager/pubspec.yaml +++ b/flutter_cache_manager/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_cache_manager description: Generic cache manager for flutter. Saves web files on the storages of the device and saves the cache info using sqflite. -version: 3.4.2 +version: 3.5.0 homepage: https://github.com/Baseflow/flutter_cache_manager/tree/develop/flutter_cache_manager topics: - cache @@ -9,24 +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/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); } diff --git a/flutter_cache_manager/test/http_file_fetcher_test.dart b/flutter_cache_manager/test/http_file_fetcher_test.dart index 34f81d0b..d6cbe0cf 100644 --- a/flutter_cache_manager/test/http_file_fetcher_test.dart +++ b/flutter_cache_manager/test/http_file_fetcher_test.dart @@ -117,4 +117,94 @@ 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 completes normally if cancelled after response received', + () async { + final token = CancellationToken(); + + final client = MockClient((request) async { + return Response.bytes(Uint8List(16), 200); + }); + + final httpFileFetcher = HttpFileService(httpClient: client); + + // 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(); + + 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'}, + ); + }); + + 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/test/mock.mocks.dart b/flutter_cache_manager/test/mock.mocks.dart index a1496764..e9832caf 100644 --- a/flutter_cache_manager/test/mock.mocks.dart +++ b/flutter_cache_manager/test/mock.mocks.dart @@ -1,13 +1,15 @@ -// 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 _i4; -import 'package:flutter_cache_manager/flutter_cache_manager.dart' as _i3; +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/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; @@ -18,6 +20,7 @@ import 'package:mockito/src/dummies.dart' as _i6; // 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 @@ -48,12 +51,12 @@ class _FakeDateTime_3 extends _i1.SmartFake implements DateTime { } class _FakeFileServiceResponse_4 extends _i1.SmartFake - implements _i3.FileServiceResponse { + implements _i2.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 _i2.FileService { _FakeFileService_5(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); } @@ -62,7 +65,7 @@ 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 _i2.CacheInfoRepository { MockCacheInfoRepositoryBase() { _i1.throwOnMissingStub(this); } @@ -273,7 +276,7 @@ class MockCacheStoreBase extends _i1.Mock implements _i5.CacheStore { ); @override - _i4.Future<_i3.FileInfo?> getFile( + _i4.Future<_i2.FileInfo?> getFile( String? key, { bool? ignoreMemCache = false, }) => @@ -283,9 +286,9 @@ class MockCacheStoreBase extends _i1.Mock implements _i5.CacheStore { [key], {#ignoreMemCache: ignoreMemCache}, ), - returnValue: _i4.Future<_i3.FileInfo?>.value(), + returnValue: _i4.Future<_i2.FileInfo?>.value(), ) - as _i4.Future<_i3.FileInfo?>); + as _i4.Future<_i2.FileInfo?>); @override _i4.Future putFile(_i2.CacheObject? cacheObject) => @@ -312,12 +315,12 @@ class MockCacheStoreBase extends _i1.Mock implements _i5.CacheStore { as _i4.Future<_i2.CacheObject?>); @override - _i4.Future<_i3.FileInfo?> getFileFromMemory(String? key) => + _i4.Future<_i2.FileInfo?> getFileFromMemory(String? key) => (super.noSuchMethod( Invocation.method(#getFileFromMemory, [key]), - returnValue: _i4.Future<_i3.FileInfo?>.value(), + returnValue: _i4.Future<_i2.FileInfo?>.value(), ) - as _i4.Future<_i3.FileInfo?>); + as _i4.Future<_i2.FileInfo?>); @override _i4.Future emptyCache() => @@ -372,7 +375,7 @@ class MockCacheStoreBase extends _i1.Mock implements _i5.CacheStore { /// 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 _i2.FileService { MockFileServiceBase() { _i1.throwOnMissingStub(this); } @@ -389,20 +392,29 @@ class MockFileServiceBase extends _i1.Mock implements _i3.FileService { ); @override - _i4.Future<_i3.FileServiceResponse> get( + _i4.Future<_i2.FileServiceResponse> get( String? url, { Map? headers, + _i2.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: _i4.Future<_i2.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 _i4.Future<_i2.FileServiceResponse>); } /// A class which mocks [WebHelper]. @@ -414,7 +426,7 @@ class MockWebHelper extends _i1.Mock implements _i7.WebHelper { } @override - _i3.FileService get fileFetcher => + _i2.FileService get fileFetcher => (super.noSuchMethod( Invocation.getter(#fileFetcher), returnValue: _FakeFileService_5( @@ -422,7 +434,7 @@ class MockWebHelper extends _i1.Mock implements _i7.WebHelper { Invocation.getter(#fileFetcher), ), ) - as _i3.FileService); + as _i2.FileService); @override int get concurrentCalls => @@ -436,11 +448,12 @@ class MockWebHelper extends _i1.Mock implements _i7.WebHelper { ); @override - _i4.Stream<_i3.FileResponse> downloadFile( + _i4.Stream<_i2.FileResponse> downloadFile( String? url, { String? key, Map? authHeaders, bool? ignoreMemCache = false, + _i2.CancellationToken? cancellationToken, }) => (super.noSuchMethod( Invocation.method( @@ -450,9 +463,10 @@ class MockWebHelper extends _i1.Mock implements _i7.WebHelper { #key: key, #authHeaders: authHeaders, #ignoreMemCache: ignoreMemCache, + #cancellationToken: cancellationToken, }, ), - returnValue: _i4.Stream<_i3.FileResponse>.empty(), + returnValue: _i4.Stream<_i2.FileResponse>.empty(), ) - as _i4.Stream<_i3.FileResponse>); + as _i4.Stream<_i2.FileResponse>); } 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 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, + ); } } 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