185 lines
7.3 KiB
Dart
185 lines
7.3 KiB
Dart
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/<id>`)
|
|
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<String> 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<int> _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<TrashItem> 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<TrashItem>> list() async {
|
|
final out = <TrashItem>[];
|
|
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<String, dynamic>;
|
|
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<String> 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<void> purge(TrashItem item) => Directory(item.entryDir).delete(recursive: true);
|
|
|
|
/// 보관 일이 지난 것을 지운다. 지운 수
|
|
Future<int> 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<int> totalSize() async => (await list()).fold<int>(0, (a, b) => a + b.size);
|
|
|
|
/// [path] 가 휴지통 폴더 자신인지 (탐색기 목록에서 숨김 · 지우기 막기에)
|
|
bool isTrashRoot(String path) => volumeRoots.any((r) => samePath(path, p.join(r, trashFolderName)));
|
|
}
|