diff --git a/lib/main.dart b/lib/main.dart index d4f1b801..cc095e34 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -251,6 +251,7 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> @override void dispose() { WidgetsBinding.instance.removeObserver(this); + CoverCacheManager.stopMaintenance(); _localLibraryEnabledSub?.close(); _downloadHistoryWarmupTimer?.cancel(); _localLibraryWarmupTimer?.cancel(); @@ -260,6 +261,7 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { + CoverCacheManager.scheduleMaintenance(); _maybeAutoScanLocalLibrary(); if (ref.exists(localLibraryProvider)) { unawaited( @@ -274,6 +276,7 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> .resumePendingDownloadsOnForeground(); } } else if (state == AppLifecycleState.paused) { + CoverCacheManager.stopMaintenance(); // Last reliable moment before the OS may kill the process: make sure // any debounced download-queue persistence reaches disk. if (ref.exists(downloadQueueProvider)) { diff --git a/lib/services/cover_cache_manager.dart b/lib/services/cover_cache_manager.dart index a8e2cd7b..47b7355a 100644 --- a/lib/services/cover_cache_manager.dart +++ b/lib/services/cover_cache_manager.dart @@ -5,6 +5,8 @@ import 'package:flutter/painting.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as p; +import 'package:spotiflac_android/utils/cache_byte_budget.dart'; +import 'package:spotiflac_android/utils/periodic_async_task.dart'; /// Persistent cache manager for album/track cover images. /// @@ -17,13 +19,30 @@ class CoverCacheManager { static const Duration _maxCacheAge = Duration(days: 365); // flutter_cache_manager only caps object count, not bytes; hi-res covers // run 300KB-1.5MB each, so 1000 objects can mean hundreds of MB on a - // storage-starved device. Sweep oldest files past the byte cap on init. + // storage-starved device. Sweep periodically while the app is foregrounded. static const int _maxCacheBytes = 150 << 20; static const int _sweepTargetBytes = 120 << 20; static CacheManager? _instance; static bool _initialized = false; - static bool _maintenanceScheduled = false; + static Future _cacheWork = Future.value(); + static final _maintenance = PeriodicAsyncTask( + interval: const Duration(minutes: 15), + run: () => _serializeCacheWork(() async { + final cachePath = _cachePath; + if (cachePath == null) return; + final deleted = await trimCacheToByteBudget( + Directory(cachePath), + maxBytes: _maxCacheBytes, + targetBytes: _sweepTargetBytes, + ); + if (deleted > 0) { + debugPrint('CoverCacheManager: Trimmed $deleted cached covers'); + } + }), + onError: (error, _) => + debugPrint('CoverCacheManager: Sweep failed: $error'), + ); static String? _cachePath; static CacheManager get instance { @@ -58,57 +77,29 @@ class CoverCacheManager { } } - /// Runs byte-cap maintenance after startup work has settled. Calling this - /// repeatedly is cheap; only the first call schedules a sweep. + /// Runs after startup and periodically thereafter. The next sweep is + /// scheduled after completion, so even slow storage cannot overlap sweeps. static void scheduleMaintenance({ Duration delay = const Duration(seconds: 20), }) { - if (_maintenanceScheduled) return; - _maintenanceScheduled = true; - unawaited( - Future.delayed(delay).then((_) async { - final cachePath = _cachePath; - if (cachePath != null) await _sweepOverByteCap(cachePath); - }), - ); + _maintenance.start(delay: delay); } - /// Deletes oldest cover files until the cache is back under - /// [_sweepTargetBytes]. Stale JSON repo entries self-heal: a missing file - /// is a cache miss and gets re-downloaded on demand. - static Future _sweepOverByteCap(String cachePath) async { - try { - final dir = Directory(cachePath); - if (!await dir.exists()) return; - - final files = []; - var totalSize = 0; - await for (final entity in dir.list(recursive: true)) { - if (entity is File && !entity.path.endsWith('.json')) { - files.add(entity); - totalSize += await entity.length(); - } - } - if (totalSize <= _maxCacheBytes) return; - - final stats = { - for (final file in files) file: await file.stat(), - }; - files.sort((a, b) => stats[a]!.modified.compareTo(stats[b]!.modified)); - for (final file in files) { - if (totalSize <= _sweepTargetBytes) break; - try { - totalSize -= stats[file]!.size; - await file.delete(); - } catch (_) {} - } - debugPrint('CoverCacheManager: Swept cover cache over byte cap'); - } catch (e) { - debugPrint('CoverCacheManager: Byte-cap sweep failed: $e'); - } + static void stopMaintenance() { + _maintenance.stop(); } - static Future clearCache() async { + static Future _serializeCacheWork(Future Function() operation) { + final next = _cacheWork.then((_) => operation()); + _cacheWork = next.catchError((Object error) { + debugPrint('CoverCacheManager: Cache maintenance failed: $error'); + }); + return next; + } + + static Future clearCache() => _serializeCacheWork(_clearCache); + + static Future _clearCache() async { if (!_initialized || _instance == null || _cachePath == null) { await initialize(); } diff --git a/lib/utils/cache_byte_budget.dart b/lib/utils/cache_byte_budget.dart new file mode 100644 index 00000000..5b08376d --- /dev/null +++ b/lib/utils/cache_byte_budget.dart @@ -0,0 +1,41 @@ +import 'dart:io'; + +/// Trims old cached payloads to a byte budget. Repository metadata, symlinks, +/// and recently written files (possibly active downloads) are left alone. +Future trimCacheToByteBudget( + Directory directory, { + required int maxBytes, + required int targetBytes, + Duration minimumAge = const Duration(minutes: 1), +}) async { + assert(targetBytes >= 0 && targetBytes <= maxBytes); + if (!await directory.exists()) return 0; + final files = <(File, FileStat)>[]; + var total = 0; + await for (final entry in directory.list( + recursive: true, + followLinks: false, + )) { + if (entry is! File || entry.path.endsWith('.json')) continue; + final stat = await entry.stat(); + if (stat.type != FileSystemEntityType.file) continue; + files.add((entry, stat)); + total += stat.size; + } + if (total <= maxBytes) return 0; + files.sort((a, b) => a.$2.modified.compareTo(b.$2.modified)); + final cutoff = DateTime.now().subtract(minimumAge); + var deleted = 0; + for (final (file, stat) in files) { + if (total <= targetBytes) break; + if (stat.modified.isAfter(cutoff)) continue; + try { + await file.delete(); + total -= stat.size; + deleted++; + } on FileSystemException { + // A cache download/eviction may have changed the directory since listing. + } + } + return deleted; +} diff --git a/lib/utils/periodic_async_task.dart b/lib/utils/periodic_async_task.dart new file mode 100644 index 00000000..ac2a194d --- /dev/null +++ b/lib/utils/periodic_async_task.dart @@ -0,0 +1,48 @@ +import 'dart:async'; + +/// Foreground maintenance that waits between completed runs and can be paused +/// safely even while a run is awaiting I/O. +class PeriodicAsyncTask { + PeriodicAsyncTask({required this.interval, required this.run, this.onError}); + final Duration interval; + final Future Function() run; + final void Function(Object, StackTrace)? onError; + Timer? _timer; + bool _enabled = false; + int _generation = 0; + Future _pending = Future.value(); + + void start({required Duration delay}) { + if (_enabled) return; + _enabled = true; + _schedule(delay, _generation); + } + + void stop() { + _enabled = false; + _generation++; + _timer?.cancel(); + _timer = null; + } + + void _schedule(Duration delay, int generation) { + _timer = Timer(delay, () async { + final operation = _pending.then((_) async { + if (!_enabled || generation != _generation) return; + try { + await run(); + } catch (error, stack) { + onError?.call(error, stack); + } + }); + _pending = operation; + try { + await operation; + } finally { + if (_enabled && generation == _generation) { + _schedule(interval, generation); + } + } + }); + } +} diff --git a/test/cover_cache_maintenance_test.dart b/test/cover_cache_maintenance_test.dart new file mode 100644 index 00000000..65bbb649 --- /dev/null +++ b/test/cover_cache_maintenance_test.dart @@ -0,0 +1,85 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:spotiflac_android/utils/cache_byte_budget.dart'; +import 'package:spotiflac_android/utils/periodic_async_task.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + testWidgets( + 'maintenance repeats without overlapping and survives pause/resume and failure', + (tester) async { + final pending = >[]; + var errors = 0; + final task = PeriodicAsyncTask( + interval: const Duration(minutes: 15), + run: () { + final done = Completer(); + pending.add(done); + return done.future; + }, + onError: (_, _) => errors++, + ); + addTearDown(task.stop); + task.start(delay: const Duration(seconds: 20)); + task.start(delay: Duration.zero); + await tester.pump(const Duration(seconds: 19)); + expect(pending, isEmpty); + await tester.pump(const Duration(seconds: 1)); + expect(pending, hasLength(1)); + await tester.pump(const Duration(minutes: 30)); + expect(pending, hasLength(1)); + task.stop(); + task.start(delay: Duration.zero); + await tester.pump(const Duration(milliseconds: 1)); + expect(pending, hasLength(1)); + pending[0].complete(); + await tester.pump(); + expect(pending, hasLength(2)); + pending[1].completeError(StateError('temporary I/O error')); + await tester.pump(); + expect(errors, 1); + await tester.pump(const Duration(minutes: 15)); + expect(pending, hasLength(3)); + task.stop(); + pending[2].complete(); + await tester.pump(const Duration(hours: 1)); + expect(pending, hasLength(3)); + }, + ); + + test( + 'cache budget trims oldest payloads while retaining metadata and new files', + () async { + final directory = await Directory.systemTemp.createTemp( + 'cover-budget-test-', + ); + addTearDown(() => directory.delete(recursive: true)); + Future file(String name, int minutesOld) async { + final file = File('${directory.path}/$name'); + await file.writeAsBytes(List.filled(100, 0)); + await file.setLastModified( + DateTime.now().subtract(Duration(minutes: minutesOld)), + ); + return file; + } + + final oldest = await file('old-cover', 60); + final newer = await file('newer-cover', 10); + final active = await file('active-cover', 0); + final metadata = await file('cache.json', 120); + expect( + await trimCacheToByteBudget(directory, maxBytes: 350, targetBytes: 150), + 0, + ); + expect( + await trimCacheToByteBudget(directory, maxBytes: 250, targetBytes: 150), + 2, + ); + expect(await oldest.exists(), isFalse); + expect(await newer.exists(), isFalse); + expect(await active.exists(), isTrue); + expect(await metadata.exists(), isTrue); + }, + ); +}