diff --git a/flutter_cache_manager/CHANGELOG.md b/flutter_cache_manager/CHANGELOG.md index 5bca3291..0791b7f5 100644 --- a/flutter_cache_manager/CHANGELOG.md +++ b/flutter_cache_manager/CHANGELOG.md @@ -1,5 +1,9 @@ ## [Unreleased] +## [3.4.4] - 2026-09-16 + +* Awaits cache-info persist in `putFile`, `putFileStream`, and downloads so the stored object has an id before those calls return ([#492](https://github.com/Baseflow/flutter_cache_manager/issues/492)). A failed repository write now throws from `putFile`/`putFileStream` and errors the download stream instead of returning a usable file with no cache-info row. + ## [3.4.3] - 2026-09-15 * 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)) diff --git a/flutter_cache_manager/lib/src/cache_manager.dart b/flutter_cache_manager/lib/src/cache_manager.dart index c04b9e7a..b700e1c7 100644 --- a/flutter_cache_manager/lib/src/cache_manager.dart +++ b/flutter_cache_manager/lib/src/cache_manager.dart @@ -242,7 +242,7 @@ class CacheManager implements BaseCacheManager { final file = await _config.fileSystem.createFile(cacheObject.relativePath); await file.writeAsBytes(fileBytes); - _store.putFile(cacheObject); + await _store.putFile(cacheObject); return file; } @@ -287,7 +287,7 @@ class CacheManager implements BaseCacheManager { .map((event) => event) .pipe(sink); - _store.putFile(cacheObject); + await _store.putFile(cacheObject); return file; } diff --git a/flutter_cache_manager/lib/src/web/web_helper.dart b/flutter_cache_manager/lib/src/web/web_helper.dart index 2144a492..d5b77146 100644 --- a/flutter_cache_manager/lib/src/web/web_helper.dart +++ b/flutter_cache_manager/lib/src/web/web_helper.dart @@ -156,11 +156,10 @@ class WebHelper { newCacheObject = newCacheObject.copyWith(length: savedBytes); } - _store.putFile(newCacheObject).then((_) { - if (newCacheObject.relativePath != oldCacheObject.relativePath) { - _removeOldFile(oldCacheObject.relativePath); - } - }); + await _store.putFile(newCacheObject); + if (newCacheObject.relativePath != oldCacheObject.relativePath) { + await _removeOldFile(oldCacheObject.relativePath); + } final file = await _store.fileSystem.createFile( newCacheObject.relativePath, @@ -232,8 +231,13 @@ class WebHelper { Future _removeOldFile(String? relativePath) async { if (relativePath == null) return; final file = await _store.fileSystem.createFile(relativePath); - if (await file.exists()) { - await file.delete(); + try { + if (await file.exists()) { + await file.delete(); + } + } on FileSystemException { + // Already deleted (see #184) or not deletable. The cache info no longer + // points at this path, so there is nothing to recover here. } } } diff --git a/flutter_cache_manager/pubspec.yaml b/flutter_cache_manager/pubspec.yaml index d56f6afa..cc50ad4e 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.3 +version: 3.4.4 homepage: https://github.com/Baseflow/flutter_cache_manager topics: - cache diff --git a/flutter_cache_manager/test/cache_manager_test.dart b/flutter_cache_manager/test/cache_manager_test.dart index 89e94737..3915fb0b 100644 --- a/flutter_cache_manager/test/cache_manager_test.dart +++ b/flutter_cache_manager/test/cache_manager_test.dart @@ -9,8 +9,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; import 'helpers/config_extensions.dart'; +import 'helpers/json_repo_helpers.dart'; import 'helpers/mock_cache_store.dart'; import 'helpers/mock_file_fetcher_response.dart'; +import 'helpers/mock_file_service.dart'; import 'helpers/test_configuration.dart'; import 'mock.mocks.dart'; @@ -423,6 +425,44 @@ void main() { expect(arg.key, fileKey); expect(arg.url, fileUrl); }); + + test('putFile waits for store persist before returning', () async { + final persisted = Completer(); + final store = MockCacheStore(); + when(store.putFile(any)).thenAnswer((_) => persisted.future); + final cacheManager = TestCacheManager(createTestConfig(), store: store); + var returned = false; + final put = cacheManager.putFile('baseflow.com/test', Uint8List(8)) + ..whenComplete(() => returned = true); + await pumpEventQueue(); + expect( + returned, + isFalse, + reason: 'putFile returned before the store persisted', + ); + persisted.complete(); + await put; + }); + + test('putFileStream waits for store persist before returning', () async { + final persisted = Completer(); + final store = MockCacheStore(); + when(store.putFile(any)).thenAnswer((_) => persisted.future); + final cacheManager = TestCacheManager(createTestConfig(), store: store); + var returned = false; + final put = cacheManager.putFileStream( + 'baseflow.com/test', + Stream>.value([1, 2, 3]), + )..whenComplete(() => returned = true); + await pumpEventQueue(); + expect( + returned, + isFalse, + reason: 'putFileStream returned before the store persisted', + ); + persisted.complete(); + await put; + }); }); group('Testing remove files from cache', () { @@ -461,6 +501,29 @@ void main() { verifyNever(store.removeCachedFile(any)); }); + test('removeFile deletes the entry right after putFile', () async { + final repo = JsonCacheInfoRepository.withFile( + await JsonRepoHelpers.createDatabaseFile(), + ); + final config = Config( + 'test', + fileSystem: TestFileSystem(), + repo: repo, + fileService: MockFileService(), + ); + final cacheManager = TestCacheManager(config); + const url = 'baseflow.com/test'; + final file = await cacheManager.putFile( + url, + Uint8List(8), + fileExtension: 'jpg', + ); + await cacheManager.removeFile(url); + await pumpEventQueue(); + expect(await repo.get(url), isNull); + expect(await file.exists(), isFalse); + }); + test("Don't crash if the cached object doesn't have an id", () async { var fileUrl = 'baseflow.com/test'; diff --git a/flutter_cache_manager/test/web_helper_test.dart b/flutter_cache_manager/test/web_helper_test.dart index 07075600..9bc655a8 100644 --- a/flutter_cache_manager/test/web_helper_test.dart +++ b/flutter_cache_manager/test/web_helper_test.dart @@ -248,6 +248,47 @@ void main() { verify(store.putFile(any)).called(1); }); + test('downloadFile waits for persist before yielding FileInfo', () async { + const imageUrl = 'baseflow.com/testimage'; + + final persisted = Completer(); + var config = createTestConfig(); + var store = _createStore(config); + when(store.putFile(any)).thenAnswer((_) => persisted.future); + + final fileService = MockFileService(); + when(fileService.get(imageUrl, headers: anyNamed('headers'))).thenAnswer(( + _, + ) { + return Future.value( + MockFileFetcherResponse( + Stream.value([0, 1, 2, 3, 4, 5]), + 6, + 'testv1', + '.jpg', + 200, + DateTime.now(), + ), + ); + }); + + final webHelper = WebHelper(store, fileService); + var yielded = false; + final download = + webHelper + .downloadFile(imageUrl) + .firstWhere((r) => r is FileInfo, orElse: null) + ..whenComplete(() => yielded = true); + await pumpEventQueue(); + expect( + yielded, + isFalse, + reason: 'downloadFile yielded FileInfo before the store persisted', + ); + persisted.complete(); + await download; + }); + test('File should be removed if extension changed', () async { const imageUrl = 'baseflow.com/testimage'; var imageName = 'image.png';