import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'package:path/path.dart' as p; import '../l10n/tr.dart'; import 'file_ops.dart' show isSameOrInside, samePath; /// X-plore 비교 e (관리자 결정 10/18): Android 앱 휴지통. Windows 는 시스템 휴지통 그대로. /// - 저장 장치마다 맨 위의 `.jj_trash` 폴더 (같은 저장 장치 안에서 이름만 바꿔 옮긴다 - 빠르고 공간이 들지 않는다). /// - 항목마다 `<시각>_<무작위>/` 폴더에 원래 것 + `info.json` (원래 경로 · 지운 때 · 폴더인지 · 크기). /// - `.nomedia` 를 두어 갤러리 · 음악 앱이 휴지통의 그림 · 동영상을 보이지 않게. /// - 보관 일 (설정, 처음 14일) 이 지나면 지운다. 0 = 휴지통 끔 (늘 영구 삭제, 예전처럼). const trashFolderName = '.jj_trash'; class TrashItem { /// 휴지통 안의 항목 폴더 (`.jj_trash/`) final String entryDir; /// 원래 경로 final String original; final DateTime deletedAt; final bool isDir; final int size; const TrashItem({required this.entryDir, required this.original, required this.deletedAt, required this.isDir, required this.size}); String get name => p.basename(original); /// 휴지통 안에 있는 원래 것 String get stored => p.join(entryDir, name); } /// 휴지통으로 옮기지 못함 (다른 저장 장치 · 권한 · 공간). 부르는 쪽이 영구 삭제할지 묻는다 class TrashMoveException implements Exception { final String path; final Object cause; const TrashMoveException(this.path, this.cause); @override String toString() => trf('휴지통으로 옮기지 못했습니다: {0} ({1})', [p.basename(path), cause]); } class AppTrash { /// 저장 장치 맨 위들 (Android: /storage/emulated/0 · /storage/XXXX-XXXX) final List volumeRoots; AppTrash(this.volumeRoots); /// [path] 가 있는 저장 장치의 휴지통 폴더 (어느 저장 장치에도 없으면 null) String? trashDirFor(String path) { String? best; for (final r in volumeRoots) { if (isSameOrInside(path, r) && (best == null || r.length > best.length)) best = r; } return best == null ? null : p.join(best, trashFolderName); } /// 휴지통 안의 것인지 (휴지통 폴더 자신 포함 - 다시 휴지통으로 보내지 않게) bool isInTrash(String path) { final t = trashDirFor(path); return t != null && isSameOrInside(path, t); } static Future _sizeOf(String path) async { final t = await FileSystemEntity.type(path, followLinks: false); if (t == FileSystemEntityType.file) return File(path).length(); if (t != FileSystemEntityType.directory) return 0; var n = 0; await for (final e in Directory(path).list(recursive: true, followLinks: false).handleError((_) {})) { if (e is File) { try { n += await e.length(); } catch (_) {} } } return n; } /// [path] 를 휴지통으로 (같은 저장 장치 안 이름 바꾸기). 못 옮기면 [TrashMoveException] - 원래 것은 그대로 Future moveToTrash(String path, {DateTime? now}) async { final trash = trashDirFor(path); if (trash == null) throw TrashMoveException(path, tr('저장 장치를 알 수 없습니다')); final when = now ?? DateTime.now(); final id = '${when.millisecondsSinceEpoch}_${Random().nextInt(1 << 30).toRadixString(36)}'; final entry = Directory(p.join(trash, id)); final isDir = await FileSystemEntity.isDirectory(path); final size = await _sizeOf(path); try { await entry.create(recursive: true); final nomedia = File(p.join(trash, '.nomedia')); if (!await nomedia.exists()) await nomedia.writeAsString(''); // 정보 먼저 (옮긴 뒤 앱이 꺼져도 어디서 왔는지 남게) final info = TrashItem(entryDir: entry.path, original: path, deletedAt: when, isDir: isDir, size: size); await File(p.join(entry.path, 'info.json')).writeAsString(jsonEncode({ 'original': path, 'deletedAt': when.toIso8601String(), 'isDir': isDir, 'size': size, })); if (isDir) { await Directory(path).rename(info.stored); } else { await File(path).rename(info.stored); } return info; } catch (e) { // 옮기지 못했으면 (이름 바꾸기가 마지막 일이라 원래 것은 제자리) 만든 항목 폴더만 지운다 try { if (await entry.exists()) await entry.delete(recursive: true); } catch (_) {} throw TrashMoveException(path, e); } } /// 휴지통의 항목들 (지운 때가 최근인 것부터) Future> list() async { final out = []; for (final r in volumeRoots) { final dir = Directory(p.join(r, trashFolderName)); if (!await dir.exists()) continue; await for (final e in dir.list(followLinks: false).handleError((_) {})) { if (e is! Directory) continue; try { final j = jsonDecode(await File(p.join(e.path, 'info.json')).readAsString()) as Map; out.add(TrashItem( entryDir: e.path, original: j['original'] as String, deletedAt: DateTime.parse(j['deletedAt'] as String), isDir: j['isDir'] == true, size: (j['size'] as num?)?.toInt() ?? 0, )); } catch (_) { // 정보가 없는 항목 (깨짐) 은 건너뛴다 - 지우지 않는다 } } } out.sort((a, b) => b.deletedAt.compareTo(a.deletedAt)); return out; } /// 되살리기: 원래 자리로. 그 자리에 같은 이름이 있으면 "이름 (되살림).확장자". 돌려준 것은 되살린 경로 Future restore(TrashItem item) async { var target = item.original; if (await FileSystemEntity.type(target, followLinks: false) != FileSystemEntityType.notFound) { final ext = item.isDir ? '' : p.extension(target); final base = item.isDir ? target : p.join(p.dirname(target), p.basenameWithoutExtension(target)); target = '$base (${tr('되살림')})$ext'; for (var i = 2; await FileSystemEntity.type(target, followLinks: false) != FileSystemEntityType.notFound; i++) { target = '$base (${tr('되살림')} $i)$ext'; } } await Directory(p.dirname(target)).create(recursive: true); if (item.isDir) { await Directory(item.stored).rename(target); } else { await File(item.stored).rename(target); } await Directory(item.entryDir).delete(recursive: true); return target; } /// 영구 삭제 Future purge(TrashItem item) => Directory(item.entryDir).delete(recursive: true); /// 보관 일이 지난 것을 지운다. 지운 수 Future purgeOlderThan(int days, {DateTime? now}) async { final limit = (now ?? DateTime.now()).subtract(Duration(days: days)); var n = 0; for (final x in await list()) { if (x.deletedAt.isBefore(limit)) { try { await purge(x); n++; } catch (_) {} } } return n; } /// 휴지통 크기 합 Future totalSize() async => (await list()).fold(0, (a, b) => a + b.size); /// [path] 가 휴지통 폴더 자신인지 (탐색기 목록에서 숨김 · 지우기 막기에) bool isTrashRoot(String path) => volumeRoots.any((r) => samePath(path, p.join(r, trashFolderName))); }