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..2eb93935 --- /dev/null +++ b/flutter_cache_manager/example/lib/test_indexeddb.dart @@ -0,0 +1,228 @@ +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/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/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 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..26b870b2 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. @@ -185,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 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..e24d15a9 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,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..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..5af41de2 --- /dev/null +++ b/flutter_cache_manager/lib/src/storage/cache_info_repositories/indexed_db_cache_info_repository.dart @@ -0,0 +1,627 @@ +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..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/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..649e27ed --- /dev/null +++ b/flutter_cache_manager/lib/src/storage/file_system/indexed_db_file.dart @@ -0,0 +1,546 @@ +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..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.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/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;