import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; import '../../data/database_helper.dart'; import '../../utils/image_path_helper.dart'; import '../../utils/user_prefs.dart'; /// 缓存清理服务 class CacheCleaner { CacheCleaner._(); static final CacheCleaner instance = CacheCleaner._(); /// 执行完整缓存清理,返回各分类删除数量 Future clean() async { final dbImagePaths = await _getAllDbImagePaths(); final deletedImages = await _cleanImageDirectory(dbImagePaths); final deletedEpubs = await _cleanOrphanedEpubBooks(); final deletedTemp = await _cleanTempDirectory(); final deletedEmptyDirs = await _cleanEmptyDirectories(); return CacheCleanResult( images: deletedImages, epubs: deletedEpubs, temp: deletedTemp, emptyDirs: deletedEmptyDirs, ); } /// 直接查 DB 收集所有图片路径(含软删除记录,与 BackupService 保持一致) Future> _getAllDbImagePaths() async { final db = await DatabaseHelper.instance.database; final paths = {}; // 影视海报 final movies = await db.query('movies', columns: ['poster_path']); for (final m in movies) { final p = m['poster_path'] as String?; if (p != null && p.isNotEmpty) paths.add(p); } // 书籍封面 final books = await db.query('books', columns: ['cover_path']); for (final b in books) { final p = b['cover_path'] as String?; if (p != null && p.isNotEmpty) paths.add(p); } // 笔记图片 final notes = await db.query('notes', columns: ['images']); for (final n in notes) { final imagesJson = n['images'] as String?; if (imagesJson != null && imagesJson.isNotEmpty) { try { for (final ip in jsonDecode(imagesJson) as List) { if (ip is String && ip.isNotEmpty) paths.add(ip); } } catch (_) {} } } // 影视海报墙图片 final moviePosters = await db.query('movie_posters', columns: ['poster_path']); for (final p in moviePosters) { final pp = p['poster_path'] as String?; if (pp != null && pp.isNotEmpty) paths.add(pp); } // 游戏封面 final games = await db.query('games', columns: ['cover_path']); for (final g in games) { final p = g['cover_path'] as String?; if (p != null && p.isNotEmpty) paths.add(p); } // 游戏截图 final gameScreenshots = await db.query('game_screenshots', columns: ['screenshot_path']); for (final s in gameScreenshots) { final p = s['screenshot_path'] as String?; if (p != null && p.isNotEmpty) paths.add(p); } // 用户头像 final userPrefs = UserPrefs(); final avatarPath = userPrefs.avatarPath; if (avatarPath != null && avatarPath.isNotEmpty) paths.add(avatarPath); return paths; } /// 规范化路径用于跨平台比较(统一分隔符、去掉末尾分隔符) /// Windows 上 DB 存的路径和文件系统遍历得到的路径分隔符可能不一致, /// 直接字符串比较会漏匹配导致图片被误删。 String _normalize(String p) { // 统一为正斜杠后再用 path.normalize 处理 .. 和 . 等 final unified = p.replaceAll('\\', '/'); return path.normalize(unified); } Future _cleanImageDirectory(Set dbImagePaths) async { int deletedCount = 0; try { final appDirPath = await ImagePathHelper.getAppDir(); final imagesDir = Directory(path.join(appDirPath, 'images')); if (!await imagesDir.exists()) return 0; // 预先规范化 DB 路径,避免每个文件都做转换 final normalizedDbPaths = dbImagePaths.map(_normalize).toSet(); await for (final entity in imagesDir.list(recursive: true, followLinks: false)) { if (entity is File && !normalizedDbPaths.contains(_normalize(entity.path)) && !path.basename(entity.path).startsWith('avatar')) { try { await entity.delete(); deletedCount++; } catch (_) {} } } } catch (e) { debugPrint('清理图片目录失败: $e'); } return deletedCount; } Future _cleanOrphanedEpubBooks() async { int deletedCount = 0; try { final db = await DatabaseHelper.instance.database; final rows = await db.query('reader_books', columns: ['id', 'file_path', 'cover_path', 'is_deleted']); final usedDirs = {}; for (final r in rows) { final isDeleted = r['is_deleted'] == 1 || r['is_deleted'] == true; if (isDeleted) continue; final id = r['id'] as String?; if (id != null && id.isNotEmpty) usedDirs.add(id); _collectEpubDirName(r['file_path'] as String?, usedDirs); _collectEpubDirName(r['cover_path'] as String?, usedDirs); } final appDirPath = await ImagePathHelper.getAppDir(); final possiblePaths = [ path.join(appDirPath, 'epub_books'), // Android 旧版绝对路径(path.join 在 Windows 上不会破坏它) '/data/user/0/top.iletter.mooknote/app_flutter/epub_books', ]; for (final epubPath in possiblePaths) { final epubDir = Directory(epubPath); if (!await epubDir.exists()) continue; await for (final entity in epubDir.list(followLinks: false)) { if (entity is Directory) { final dirName = path.basename(entity.path); if (!usedDirs.contains(dirName)) { try { await entity.delete(recursive: true); deletedCount++; } catch (_) {} } } } } } catch (e) { debugPrint('清理 epub_books 目录失败: $e'); } return deletedCount; } /// 从路径中提取 epub_books/{bookId} 的 bookId 部分 /// 兼容 Windows(\) 和 Unix(/) 分隔符 void _collectEpubDirName(String? pathStr, Set dirs) { if (pathStr == null || pathStr.isEmpty) return; // 统一为正斜杠便于查找 marker final unified = pathStr.replaceAll('\\', '/'); final marker = '/epub_books/'; final idx = unified.indexOf(marker); if (idx < 0) return; final rest = unified.substring(idx + marker.length); final slashIdx = rest.indexOf('/'); dirs.add(slashIdx >= 0 ? rest.substring(0, slashIdx) : rest); } /// mooknote 自己产生的临时文件名前缀 static const _tempPrefixes = [ 'book_poster_', 'movie_poster_', 'note_share_', 'mooknote_download', 'mooknote_bidir', ]; bool _isMooknoteTempFile(String name) { for (final prefix in _tempPrefixes) { if (name.startsWith(prefix)) return true; } return false; } Future _cleanTempDirectory() async { int deletedCount = 0; final now = DateTime.now(); try { final tempDir = await getTemporaryDirectory(); if (await tempDir.exists()) { await for (final entity in tempDir.list(followLinks: false)) { if (entity is File) { final name = path.basename(entity.path); if (_isMooknoteTempFile(name)) { try { final stat = await entity.stat(); if (now.difference(stat.modified).inHours >= 1) { await entity.delete(); deletedCount++; } } catch (_) {} } } } } } catch (e) { debugPrint('清理临时目录失败: $e'); } // cacheDir 只删 mooknote 自己产生的临时文件,不再无差别全清 // (Windows/Flutter 引擎也在该目录放缓存文件,全清可能误伤) try { final cacheDir = await getApplicationCacheDirectory(); if (await cacheDir.exists()) { await for (final entity in cacheDir.list(recursive: true, followLinks: false)) { if (entity is File) { final name = path.basename(entity.path); if (_isMooknoteTempFile(name)) { try { await entity.delete(); deletedCount++; } catch (_) {} } } } } } catch (e) { debugPrint('清理缓存目录失败: $e'); } return deletedCount; } Future _cleanEmptyDirectories() async { int deletedCount = 0; try { final appDirPath = await ImagePathHelper.getAppDir(); final cacheDir = await getApplicationCacheDirectory(); final dirs = [ Directory(path.join(appDirPath, 'images')), Directory(path.join(appDirPath, 'epub_books')), cacheDir, ]; for (final dir in dirs) { if (!await dir.exists()) continue; deletedCount += await _removeEmptyDirsRecursive(dir); } } catch (e) { debugPrint('清理空文件夹失败: $e'); } return deletedCount; } Future _removeEmptyDirsRecursive(Directory dir) async { int count = 0; try { final children = await dir.list(followLinks: false).toList(); for (final child in children) { if (child is Directory) { count += await _removeEmptyDirsRecursive(child); final remaining = await child.list(followLinks: false).toList(); if (remaining.isEmpty) { try { await child.delete(); count++; } catch (_) {} } } } } catch (_) {} return count; } } class CacheCleanResult { final int images; final int epubs; final int temp; final int emptyDirs; const CacheCleanResult({ required this.images, required this.epubs, required this.temp, required this.emptyDirs, }); int get total => images + epubs + temp + emptyDirs; String get description => '已清理 $images 个孤立图片,$epubs 个孤立电子书,$temp 个临时文件,$emptyDirs 个空文件夹'; }