diff --git a/flutter_cache_manager/CHANGELOG.md b/flutter_cache_manager/CHANGELOG.md index 97c1a155..d8ba2624 100644 --- a/flutter_cache_manager/CHANGELOG.md +++ b/flutter_cache_manager/CHANGELOG.md @@ -1,6 +1,7 @@ ## [Unreleased] * 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)) +* Awaits cache-info persist in `putFile`, `putFileStream`, and downloads so the stored object has an id before those calls return * 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 * Migrates example Android app to built-in Kotlin 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 126a2c8f..729938d8 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/test/cache_manager_test.dart b/flutter_cache_manager/test/cache_manager_test.dart index 95242aa0..27b4abdd 100644 --- a/flutter_cache_manager/test/cache_manager_test.dart +++ b/flutter_cache_manager/test/cache_manager_test.dart @@ -9,9 +9,13 @@ import 'package:flutter_cache_manager/src/web/web_helper.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; +import 'package:flutter_cache_manager/src/config/config.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'; @@ -424,6 +428,40 @@ 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', () { @@ -462,6 +500,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..9ce2e212 100644 --- a/flutter_cache_manager/test/web_helper_test.dart +++ b/flutter_cache_manager/test/web_helper_test.dart @@ -248,6 +248,46 @@ 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';