diff --git a/lib/services/cover_cache_manager.dart b/lib/services/cover_cache_manager.dart index 47b7355a..a914c687 100644 --- a/lib/services/cover_cache_manager.dart +++ b/lib/services/cover_cache_manager.dart @@ -113,17 +113,16 @@ class CoverCacheManager { await instance.emptyCache(); } catch (e) { debugPrint('CoverCacheManager: emptyCache failed, fallback to wipe: $e'); + await _wipeDirectory(cachePath); } - await _wipeDirectory(cachePath); - final imageCache = PaintingBinding.instance.imageCache; imageCache.clear(); imageCache.clearLiveImages(); + // Pending downloads publish into this manager when their files finish. + // Preserve their unindexed files and the single repository writer. instance.store.emptyMemoryCache(); - _instance = _createManager(cachePath); - _initialized = true; } static Future getStats() async { diff --git a/lib/widgets/cached_cover_image.dart b/lib/widgets/cached_cover_image.dart index bbfbd055..e9730282 100644 --- a/lib/widgets/cached_cover_image.dart +++ b/lib/widgets/cached_cover_image.dart @@ -5,6 +5,7 @@ import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; +import 'package:spotiflac_android/utils/image_cache_utils.dart'; class CachedCoverImage extends StatelessWidget { static const int _defaultMinCacheExtent = 64; @@ -340,7 +341,9 @@ Future precacheMetadataBackdrop( if (normalized == null || normalized.isEmpty) return; final ImageProvider provider; - if (normalized.startsWith('http://') || normalized.startsWith('https://')) { + final network = + normalized.startsWith('http://') || normalized.startsWith('https://'); + if (network) { provider = cachedCoverImageProvider(normalized); } else if (!normalized.startsWith('content://')) { final filePath = normalized.startsWith('file://') @@ -354,7 +357,7 @@ Future precacheMetadataBackdrop( final extent = metadataBackdropCacheExtent(context); try { await precacheImage( - ResizeImage(provider, width: extent, height: extent), + ResizeImage(provider, width: extent, height: network ? null : extent), context, onError: (_, _) {}, ).timeout(const Duration(milliseconds: 200)); @@ -368,13 +371,10 @@ void precacheCoverImage(BuildContext context, String? url) { if (!url.startsWith('http://') && !url.startsWith('https://')) { return; } - final dpr = MediaQuery.devicePixelRatioOf(context).clamp(1.0, 3.0).toDouble(); - final targetSize = (360 * dpr).round().clamp(512, 1024).toInt(); precacheImage( ResizeImage( cachedCoverImageProvider(url), - width: targetSize, - height: targetSize, + width: coverCacheWidthForViewport(context), ), context, ); diff --git a/test/cached_cover_decode_test.dart b/test/cached_cover_decode_test.dart index d46d3c09..563fac02 100644 --- a/test/cached_cover_decode_test.dart +++ b/test/cached_cover_decode_test.dart @@ -4,11 +4,111 @@ import 'dart:ui' as ui; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:spotiflac_android/utils/image_cache_utils.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); + for (final source in [ + 'network cover', + 'network backdrop', + 'local backdrop', + ]) { + testWidgets('metadata $source prewarm reuses the display bitmap', ( + tester, + ) async { + final cache = PaintingBinding.instance.imageCache; + cache.clear(); + cache.clearLiveImages(); + late BuildContext context; + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData( + size: Size(393, 852), + devicePixelRatio: 2, + ), + child: Builder( + builder: (value) { + context = value; + return const SizedBox(); + }, + ), + ), + ), + ); + final network = source != 'local backdrop'; + final backdrop = source != 'network cover'; + const url = 'https://example.invalid/metadata-prewarm.png'; + final ImageProvider provider = network + ? cachedCoverImageProvider(url) + : FileImage(File('/metadata-prewarm.png')); + final width = backdrop + ? metadataBackdropCacheExtent(context) + : coverCacheWidthForViewport(context); + final display = ResizeImage( + provider, + width: width, + height: network ? null : width, + ); + final key = await display.obtainKey(ImageConfiguration.empty); + final image = await tester.runAsync(() async { + final recorder = ui.PictureRecorder(); + Canvas(recorder).drawColor(Colors.blue, BlendMode.src); + final picture = recorder.endRecording(); + final image = await picture.toImage(1, 1); + picture.dispose(); + return image; + }); + cache.putIfAbsent( + key, + () => OneFrameImageStreamCompleter( + Future.value(ImageInfo(image: image!)), + ), + ); + await tester.pump(); + try { + await tester.runAsync(() async { + if (backdrop) { + await precacheMetadataBackdrop( + context, + network ? url : '/metadata-prewarm.png', + ); + } else { + precacheCoverImage(context, url); + await Future.delayed(Duration.zero); + } + }); + expect(cache.statusForKey(key).live, isTrue); + expect(cache.pendingImageCount, 0); + expect(cache.currentSize, 1); + await tester.pumpWidget( + MaterialApp( + home: network + ? CachedCoverImage( + imageUrl: url, + memCacheWidth: width, + memCacheHeight: backdrop ? width : null, + ) + : Image.file( + File('/metadata-prewarm.png'), + cacheWidth: width, + cacheHeight: width, + ), + ), + ); + expect(tester.widget(find.byType(RawImage)).image, isNotNull); + expect(cache.currentSize, 1); + expect(tester.takeException(), isNull); + } finally { + await tester.pumpWidget(const SizedBox()); + cache.clear(); + cache.clearLiveImages(); + } + }); + } + for (final explicit in [false, true]) { testWidgets( 'grid decode follows constraints, explicit override=$explicit', diff --git a/test/cover_cache_clear_test.dart b/test/cover_cache_clear_test.dart new file mode 100644 index 00000000..778cac06 --- /dev/null +++ b/test/cover_cache_clear_test.dart @@ -0,0 +1,136 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; + +class _CacheBinding extends AutomatedTestWidgetsFlutterBinding { + @override + bool get overrideHttpClient => false; +} + +void main() { + _CacheBinding(); + test('clear retains pending covers and one persistent cache index', () async { + final directory = await Directory.systemTemp.createTemp('cover-clear-'); + const channel = MethodChannel('plugins.flutter.io/path_provider'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(channel, (_) async => directory.path); + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final release = Completer(); + final prefixSent = Completer(); + CacheManager? manager; + Future? pendingWork; + addTearDown(() async { + if (!release.isCompleted) release.complete(); + await server.close(force: true); + await pendingWork?.timeout( + const Duration(seconds: 5), + onTimeout: () => throw TimeoutException( + 'Pending HTTP download did not stop during cleanup', + ), + ); + await manager?.dispose(); + messenger.setMockMethodCallHandler(channel, null); + await directory.delete(recursive: true); + }); + final cover = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl6qVQAAAAASUVORK5CYII=', + ); + final requests = {}; + server.listen((request) async { + final path = request.uri.path; + requests.update(path, (count) => count + 1, ifAbsent: () => 1); + final response = request.response + ..bufferOutput = false + ..persistentConnection = false + ..headers.contentType = ContentType('image', 'png') + ..headers.set(HttpHeaders.cacheControlHeader, 'max-age=3600') + ..contentLength = cover.length; + if (path == '/pending.png') { + response.add(cover.sublist(0, 32)); + await response.flush(); + prefixSent.complete(); + await release.future; + response.add(cover.sublist(32)); + } else { + response.add(cover); + } + await response.close(); + }); + await CoverCacheManager.initialize(); + final original = CoverCacheManager.instance; + manager = original; + original.store.cleanupRunMinInterval = Duration.zero; + const staleKey = 'https://example.invalid/stale-cover.png'; + final stale = await original.putFile(staleKey, cover, fileExtension: 'png'); + final base = 'http://${server.address.address}:${server.port}'; + final pendingUrl = '$base/pending.png'; + final afterUrl = '$base/after.png'; + final pending = original.getSingleFile(pendingUrl); + pendingWork = pending.then((_) {}, onError: (Object _) {}); + await prefixSent.future.timeout( + const Duration(seconds: 5), + onTimeout: () => + throw TimeoutException('Deferred HTTP prefix was not flushed'), + ); + final partial = await _waitForPartialCover(stale); + expect(await partial.readAsBytes(), cover.sublist(0, 32)); + await CoverCacheManager.clearCache(); + expect(CoverCacheManager.instance, same(original)); + expect(await stale.exists(), isFalse); + expect(await partial.exists(), isTrue); + expect(await original.getFileFromCache(staleKey), isNull); + final after = await CoverCacheManager.instance + .getSingleFile(afterUrl) + .timeout( + const Duration(seconds: 5), + onTimeout: () => throw TimeoutException( + 'Cover requested after clear did not finish', + ), + ); + release.complete(); + final completed = await pending.timeout( + const Duration(seconds: 5), + onTimeout: () => throw TimeoutException( + 'Pending streamed cover did not finish after release', + ), + ); + expect(await completed.readAsBytes(), cover); + expect(await after.readAsBytes(), cover); + for (final url in [pendingUrl, afterUrl]) { + final cached = await CoverCacheManager.instance.getFileFromCache(url); + expect(cached, isNotNull); + expect(await cached!.file.readAsBytes(), cover); + } + final repository = original.config.repo as JsonCacheInfoRepository; + final rows = + jsonDecode(await File(repository.path!).readAsString()) + as List; + expect(rows.map((row) => (row as Map)['key']).toSet(), { + pendingUrl, + afterUrl, + }); + expect(requests, {'/pending.png': 1, '/after.png': 1}); + }); +} + +Future _waitForPartialCover(File stale) async { + final directory = Directory(stale.parent.path); + final deadline = DateTime.now().add(const Duration(seconds: 5)); + while (DateTime.now().isBefore(deadline)) { + await for (final file in directory.list()) { + if (file is File && file.path != stale.path && await file.length() > 0) { + return file; + } + } + await Future.delayed(const Duration(milliseconds: 10)); + } + throw TimeoutException( + 'Pending cover prefix was not written to its cache file', + ); +}