diff --git a/assets/l10n/en.json b/assets/l10n/en.json index ccccfd8..9471eeb 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -36,6 +36,8 @@ "\"{0}\" 에 처음 연결합니다": "Connecting to \"{0}\" for the first time", "\"{0}\" 에는 없고 대상 \"{1}\" 에만 있는 항목입니다. 지우기 포함으로 맞추면 대상에서 지워집니다 (되돌릴 수 없음).": "These items are not in \"{0}\" but only in the target \"{1}\". Syncing with delete removes them from the target (cannot be undone).", "\"{0}\" 은(는) 비밀번호가 걸려 있습니다.": "\"{0}\" is password-protected.", + "\"{0}\" 을 꺼낼 수 없습니다": "Cannot export \"{0}\"", + "\"{0}\" 을 보관함 목록에서 뺍니다. 남아 있는 그 내용 파일도 지웁니다 (꺼낼 수 없는 항목이라 잃는 것은 없습니다)": "Removes \"{0}\" from the vault list. Its remaining content file is also deleted (nothing is lost, since this item cannot be exported)", "\"{0}\" 을 보관함에서 지웁니다. 되돌릴 수 없습니다 (먼저 [꺼내기] 를 하지 않았으면 내용이 사라집니다)": "Delete \"{0}\" from the vault. This cannot be undone (if you have not used [Export] first, the content is lost)", "\"{0}\" 을(를) \"{1}\" 폴더로 옮길까요?": "Move \"{0}\" to the \"{1}\" folder?", "\"{0}\" 을(를) 지울까요?": "Delete \"{0}\"?", @@ -1516,6 +1518,7 @@ "보관함 열기": "Open vault", "보관함 열쇠 한 벌을 이 기기의 안전 저장소에 두고, 마스터 비밀번호로도 열 수 있게 합니다. 보관함 비밀번호를 잊어도 열 수 있지만, 마스터 비밀번호를 아는 사람도 열 수 있게 됩니다. 마스터를 초기화하면 이 길만 없어지고 보관함 비밀번호는 그대로입니다.": "Keeps a copy of the vault key in this device's secure storage so the master password can also open the vault. You can open it even if you forget the vault password, but anyone who knows the master password can open it too. Resetting the master password removes only this path; the vault password stays the same.", "보관함 자동 잠금": "Vault auto-lock", + "보관함 파일이 바뀌었거나 빠졌습니다 - 이 항목은 꺼낼 수 없습니다 (보관함 폴더를 다른 곳에서 바꿨는지 확인하세요)": "The vault file was changed or is missing - this item cannot be exported (check whether the vault folder was changed elsewhere)", "보관함 폴더를 열 수 없습니다: {0}": "Cannot open the vault folder: {0}", "보관함에 넣기": "Add to vault", "보관함에 넣기 · {0}개": "Add to vault · {0}", @@ -1641,6 +1644,7 @@ "빠른 보기": "Quick look", "빠른 필터": "Quick filter", "빼고 싶은 것 (선택)": "What to avoid (optional)", + "빼기": "Remove", "사라지는 것": "What is lost", "사본 만들기": "Make a copy", "사용 안 함": "Off", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index ff443b2..1cda6aa 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -36,6 +36,8 @@ "\"{0}\" 에 처음 연결합니다": "「{0}」に初めて接続します", "\"{0}\" 에는 없고 대상 \"{1}\" 에만 있는 항목입니다. 지우기 포함으로 맞추면 대상에서 지워집니다 (되돌릴 수 없음).": "「{0}」にはなく、対象「{1}」にだけある項目です。削除を含めて同期すると対象から削除されます (元に戻せません)。", "\"{0}\" 은(는) 비밀번호가 걸려 있습니다.": "「{0}」にはパスワードが設定されています。", + "\"{0}\" 을 꺼낼 수 없습니다": "「{0}」を取り出せません", + "\"{0}\" 을 보관함 목록에서 뺍니다. 남아 있는 그 내용 파일도 지웁니다 (꺼낼 수 없는 항목이라 잃는 것은 없습니다)": "「{0}」を保管庫の一覧から外します。残っているその内容ファイルも削除します (取り出せない項目なので失うものはありません)", "\"{0}\" 을 보관함에서 지웁니다. 되돌릴 수 없습니다 (먼저 [꺼내기] 를 하지 않았으면 내용이 사라집니다)": "「{0}」を保管庫から削除します。元に戻せません (先に [取り出す] をしていなければ内容は失われます)", "\"{0}\" 을(를) \"{1}\" 폴더로 옮길까요?": "「{0}」を「{1}」フォルダーに移動しますか?", "\"{0}\" 을(를) 지울까요?": "「{0}」を削除しますか?", @@ -1516,6 +1518,7 @@ "보관함 열기": "保管庫を開く", "보관함 열쇠 한 벌을 이 기기의 안전 저장소에 두고, 마스터 비밀번호로도 열 수 있게 합니다. 보관함 비밀번호를 잊어도 열 수 있지만, 마스터 비밀번호를 아는 사람도 열 수 있게 됩니다. 마스터를 초기화하면 이 길만 없어지고 보관함 비밀번호는 그대로입니다.": "保管庫の鍵の写しをこの端末の安全な保存先に置き、マスターパスワードでも開けるようにします。保管庫のパスワードを忘れても開けますが、マスターパスワードを知っている人も開けるようになります。マスターを初期化するとこの方法だけがなくなり、保管庫のパスワードはそのままです。", "보관함 자동 잠금": "保管庫の自動ロック", + "보관함 파일이 바뀌었거나 빠졌습니다 - 이 항목은 꺼낼 수 없습니다 (보관함 폴더를 다른 곳에서 바꿨는지 확인하세요)": "保管庫のファイルが変更されたか、なくなっています - この項目は取り出せません (保管庫フォルダーを他の場所で変更していないか確認してください)", "보관함 폴더를 열 수 없습니다: {0}": "保管庫のフォルダーを開けません: {0}", "보관함에 넣기": "保管庫に入れる", "보관함에 넣기 · {0}개": "保管庫に入れる・{0} 件", @@ -1641,6 +1644,7 @@ "빠른 보기": "クイックルック", "빠른 필터": "クイックフィルター", "빼고 싶은 것 (선택)": "避けたいもの (任意)", + "빼기": "外す", "사라지는 것": "消えるもの", "사본 만들기": "コピーを作る", "사용 안 함": "使用しない", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 4addf7c..0016fef 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -36,6 +36,8 @@ "\"{0}\" 에 처음 연결합니다": "首次连接\"{0}\"", "\"{0}\" 에는 없고 대상 \"{1}\" 에만 있는 항목입니다. 지우기 포함으로 맞추면 대상에서 지워집니다 (되돌릴 수 없음).": "这些项目不在“{0}”中,只在目标“{1}”中。包含删除同步后会从目标中删除 (无法撤销)。", "\"{0}\" 은(는) 비밀번호가 걸려 있습니다.": "“{0}”设有密码。", + "\"{0}\" 을 꺼낼 수 없습니다": "无法导出“{0}”", + "\"{0}\" 을 보관함 목록에서 뺍니다. 남아 있는 그 내용 파일도 지웁니다 (꺼낼 수 없는 항목이라 잃는 것은 없습니다)": "将“{0}”从保管库列表中移除,并删除残留的内容文件 (该项目无法导出,因此不会丢失任何内容)", "\"{0}\" 을 보관함에서 지웁니다. 되돌릴 수 없습니다 (먼저 [꺼내기] 를 하지 않았으면 내용이 사라집니다)": "将从保管库中删除“{0}”。此操作无法撤销 (若未先 [导出],内容将丢失)", "\"{0}\" 을(를) \"{1}\" 폴더로 옮길까요?": "将“{0}”移到“{1}”文件夹吗?", "\"{0}\" 을(를) 지울까요?": "要删除“{0}”吗?", @@ -1516,6 +1518,7 @@ "보관함 열기": "打开保管库", "보관함 열쇠 한 벌을 이 기기의 안전 저장소에 두고, 마스터 비밀번호로도 열 수 있게 합니다. 보관함 비밀번호를 잊어도 열 수 있지만, 마스터 비밀번호를 아는 사람도 열 수 있게 됩니다. 마스터를 초기화하면 이 길만 없어지고 보관함 비밀번호는 그대로입니다.": "在本设备的安全存储中保存一份保管库密钥,使主密码也能打开保管库。即使忘记保管库密码也能打开,但知道主密码的人也能打开。重置主密码只会移除此方式,保管库密码保持不变。", "보관함 자동 잠금": "保管库自动锁定", + "보관함 파일이 바뀌었거나 빠졌습니다 - 이 항목은 꺼낼 수 없습니다 (보관함 폴더를 다른 곳에서 바꿨는지 확인하세요)": "保管库文件已被更改或缺失 - 无法导出此项目 (请检查保管库文件夹是否在其他地方被更改)", "보관함 폴더를 열 수 없습니다: {0}": "无法打开保管库文件夹: {0}", "보관함에 넣기": "放入保管库", "보관함에 넣기 · {0}개": "放入保管库 · {0} 个", @@ -1641,6 +1644,7 @@ "빠른 보기": "快速查看", "빠른 필터": "快速筛选", "빼고 싶은 것 (선택)": "要排除的内容 (可选)", + "빼기": "移除", "사라지는 것": "丢失的内容", "사본 만들기": "创建副本", "사용 안 함": "不使用", diff --git a/lib/core/remote_thumbs.dart b/lib/core/remote_thumbs.dart index ed68c57..04a6a55 100644 --- a/lib/core/remote_thumbs.dart +++ b/lib/core/remote_thumbs.dart @@ -42,6 +42,9 @@ class RemoteThumbs { /// 이미 만든 것 (없으면 null - 만들기는 [request]) File? cached(String key) => _done[key]; + /// [path] 의 기억을 잊는다 (보관함에 넣은 파일 - 관리자 지시 10/21. 파일은 removeThumbCaches 가 지움) + void forget(String path) => _done.removeWhere((k, _) => k.startsWith('$path|')); + /// 썸네일을 달라고 줄 세운다. [wanted] 가 시작할 때 false 면 (화면에서 사라진 줄) 건너뛴다. /// 끝나면 [onDone] (못 만들었으면 null). 받지 않는 망이면 줄 세우지 않고 null Future request(String path, String key, {required bool Function() wanted}) async { diff --git a/lib/core/thumb_cache.dart b/lib/core/thumb_cache.dart new file mode 100644 index 0000000..699a441 --- /dev/null +++ b/lib/core/thumb_cache.dart @@ -0,0 +1,40 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:path/path.dart' as p; + +/// 앱이 만든 작은 그림 (썸네일) 캐시 (관리자 지시 10/21, 28 지적): 암호 보관함에 넣은 파일의 썸네일이 캐시에 남지 않게 지운다. +/// 캐시 이름은 "경로|크기|바뀐 때 (ms)" (빠른 보기 큰 것은 끝에 "|640") 의 md5 + .jpg - 파일 탐색기 동영상 썸네일 (jj_thumbs) · +/// 원격 썸네일 (jj_rthumbs) 이 같은 꼴을 쓴다 +const thumbCacheDirs = ['jj_thumbs', 'jj_rthumbs']; + +List thumbCacheKeys(String path, int size, DateTime modified) { + final k = '$path|$size|${modified.millisecondsSinceEpoch}'; + return [k, '$k|640']; +} + +/// 메모리에 둔 썸네일 기억을 잊는 곳들 (화면 쪽이 등록 - 경로를 받아 그 경로의 것만 잊음) +final thumbMemoryForgetters = {}; + +/// [path] 의 썸네일 캐시 파일을 지우고 메모리 기억도 잊는다. 지운 파일 수 +Future removeThumbCaches(String tempDir, String path, int size, DateTime modified) async { + var n = 0; + for (final d in thumbCacheDirs) { + for (final k in thumbCacheKeys(path, size, modified)) { + final f = File(p.join(tempDir, d, '${md5.convert(utf8.encode(k))}.jpg')); + try { + if (await f.exists()) { + await f.delete(); + n++; + } + } catch (_) {} + } + } + for (final forget in thumbMemoryForgetters) { + try { + forget(path); + } catch (_) {} + } + return n; +} diff --git a/lib/core/vault_store.dart b/lib/core/vault_store.dart index 598e9af..7c599d7 100644 --- a/lib/core/vault_store.dart +++ b/lib/core/vault_store.dart @@ -402,7 +402,10 @@ class Vault { /// [VaultAuthException] 이고 파일을 남기지 않음). [onTemp] · [onTempDone]: 받는 폴더에 잠깐 쓰는 "이름.jjvtmp" (평문) 를 /// 앱 전용 기록에 적고 지우게 (강제로 꺼져 남으면 다음에 켤 때 지움 - 관리자 지시 10/21) Future export(VaultEntry e, String outPath, {void Function(String tmp)? onTemp, void Function(String tmp)? onTempDone}) async { - final f = await File(p.join(dir.path, '${e.blob}$_ext')).open(); + // 내용 파일이 없으면 (목록을 예전 것으로 되돌렸거나 폴더를 다른 곳에서 바꿈) 짝이 맞지 않는 것과 같게 (28 지적 · 관리자 지시 10/21) + final blob = File(p.join(dir.path, '${e.blob}$_ext')); + if (!await blob.exists()) throw const VaultAuthException(); + final f = await blob.open(); final tmp = File('$outPath.jjvtmp'); onTemp?.call(tmp.path); RandomAccessFile w; @@ -453,7 +456,9 @@ class Vault { /// 메모리로 푼다 (관리자 지시 10/21: 그림 보기는 파일로 풀지 않음). [maxBytes] 보다 크면 [StateError] Future readBytes(VaultEntry e, {int maxBytes = 256 * 1024 * 1024}) async { if (e.size > maxBytes) throw StateError('too big'); - final data = await File(p.join(dir.path, '${e.blob}$_ext')).readAsBytes(); + final blob = File(p.join(dir.path, '${e.blob}$_ext')); + if (!await blob.exists()) throw const VaultAuthException(); + final data = await blob.readAsBytes(); final (id, _) = VaultCrypto.parseHeader(Uint8List.sublistView(data, 0, min(data.length, VaultCrypto.headerLength))); _checkId(e, id); final out = VaultCrypto.decryptAll(_key, data); diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 6b6f93f..3854211 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -58,6 +58,7 @@ import '../l10n/tr.dart'; import 'explorer_bookmarks.dart'; import 'explorer_search.dart'; import 'remote_thumb_view.dart'; +import '../core/thumb_cache.dart' show thumbMemoryForgetters; import '../core/shizuku.dart'; import 'vault_page.dart' show openVault; import 'app_manager_page.dart' show openAppManager; @@ -446,6 +447,10 @@ class _ExplorerPageState extends State { @override void initState() { + // 관리자 지시 (10/21): 보관함에 넣은 파일의 썸네일 기억을 잊게 (static 함수라 여러 번 넣어도 하나) + thumbMemoryForgetters + ..add(_Thumbs.forget) + ..add(_BigThumbs.forget); super.initState(); if (widget.rsync) { ExplorerPage._openRsync++; @@ -6276,6 +6281,9 @@ class _Thumbs { static final _cache = >{}; static final _q = _Queue(1); + /// 보관함에 넣은 파일의 기억을 잊는다 (관리자 지시 10/21) + static void forget(String path) => _cache.removeWhere((k, _) => k.startsWith('$path|')); + static Future of(AppController c, FileEntry e) { final key = '${e.path}|${e.size}|${e.modified.millisecondsSinceEpoch}'; return _cache[key] ??= _q.run(() async { @@ -6359,6 +6367,8 @@ class _BigThumbs { static final _cache = >{}; static final _q = _Queue(1); + static void forget(String path) => _cache.removeWhere((k, _) => k.startsWith('$path|')); + static Future of(AppController c, FileEntry e) { final key = '${e.path}|${e.size}|${e.modified.millisecondsSinceEpoch}|640'; return _cache[key] ??= _q.run(() async { diff --git a/lib/ui/remote_thumb_view.dart b/lib/ui/remote_thumb_view.dart index 7aff535..00f058a 100644 --- a/lib/ui/remote_thumb_view.dart +++ b/lib/ui/remote_thumb_view.dart @@ -11,6 +11,7 @@ import '../core/playlist.dart' show isVideoFile; import '../core/remote_stream.dart'; import '../core/remote_thumbs.dart'; import '../core/shizuku.dart' show isShizuku; +import '../core/thumb_cache.dart' show thumbMemoryForgetters; import '../core/vfs.dart' show DavPath, isWebDav; import '../core/webdav.dart' show DavClient; import 'theme.dart'; @@ -20,12 +21,14 @@ Future? _hub; Future remoteThumbsOf(AppController c) => _hub ??= () async { final dir = Directory(p.join(await c.services.storage.tempDirectory(), 'jj_rthumbs')); - return RemoteThumbs( + final h = RemoteThumbs( dir: dir, maxBytes: () => c.settings.explorerRemoteThumbCacheMb * 1024 * 1024, allowed: () => remoteThumbsAllowedNow(c), make: (path, out) => _make(c, path, out), ); + thumbMemoryForgetters.add(h.forget); + return h; }(); /// 시험: 다음에 새로 만든다 diff --git a/lib/ui/vault_page.dart b/lib/ui/vault_page.dart index 7305854..bc56d79 100644 --- a/lib/ui/vault_page.dart +++ b/lib/ui/vault_page.dart @@ -17,7 +17,8 @@ import '../core/secret_gate.dart'; import '../core/vault_crypto.dart'; import '../core/vault_store.dart'; import '../core/vault_view.dart'; -import '../core/vfs.dart' show isDav, vBasename, vDisplay, vIsDir, vList, vLocalCopy; +import '../core/thumb_cache.dart' show removeThumbCaches; +import '../core/vfs.dart' show isDav, vBasename, vDisplay, vIsDir, vList, vLocalCopy, vStat; import '../l10n/tr.dart'; import 'player_page.dart'; import 'reader_page.dart' show openReader; @@ -398,6 +399,8 @@ class _VaultPageState extends State with WidgetsBindingObserver { String? fetched; try { var src = x.src; + // 관리자 지시 (10/21, 28 지적): 넣은 원본의 썸네일 캐시 (이름이 경로 · 크기 · 바뀐 때) 를 지우려고 미리 봄 + final st = await vStat(x.src); if (isDav(src)) { // 원격은 앱 전용 폴더 (jj_vault_view_… - 강제로 꺼져 남아도 다음에 켤 때 지움) 에 받고, 넣은 뒤 바로 지운다 tmp ??= await Directory(p.join((await vaultViewRootOf()).path, '${vaultViewPrefix}in_${DateTime.now().microsecondsSinceEpoch}')) @@ -405,6 +408,7 @@ class _VaultPageState extends State with WidgetsBindingObserver { src = fetched = await vLocalCopy(x.src, tmp.path); } await _vault!.add(src, name: vBasename(x.src), folder: x.folder); + if (st != null) await removeThumbCaches(await c.services.storage.tempDirectory(), x.src, st.size, st.modified); done++; } catch (e) { bad.add(x.top); @@ -417,6 +421,12 @@ class _VaultPageState extends State with WidgetsBindingObserver { bad.addAll(paths); _snack(trf('넣지 못했습니다: {0}', [e])); } finally { + // 화면 그림 기억 (Flutter 메모리) 에 남은 원본 그림도 비움 + if (done > 0) { + PaintingBinding.instance.imageCache + ..clear() + ..clearLiveImages(); + } if (tmp != null) await _removeDir(tmp); if (mounted) { setState(() { @@ -459,7 +469,8 @@ class _VaultPageState extends State with WidgetsBindingObserver { await openReader(context, c, _VaultImageSource(e.name, bytes)); } catch (err) { if (mounted) setState(() => _busy = false); - _snack(err is VaultAuthException ? tr('이 파일은 바뀌었거나 깨져 풀 수 없습니다') : trf('열 수 없습니다: {0}', [err])); + if (err is VaultAuthException) return _broken(e, err); + _snack(trf('열 수 없습니다: {0}', [err])); } return; } @@ -485,10 +496,52 @@ class _VaultPageState extends State with WidgetsBindingObserver { await File(out).delete().catchError((_) => File(out)); } catch (err) { if (mounted) setState(() => _busy = false); - _snack(err is VaultAuthException ? tr('이 파일은 바뀌었거나 깨져 풀 수 없습니다') : trf('열 수 없습니다: {0}', [err])); + if (err is VaultAuthException) return _broken(e, err); + _snack(trf('열 수 없습니다: {0}', [err])); } } + /// 내용 파일이 없거나 바뀐 항목 (관리자 지시 10/21, 28 지적): 이유 · [자세히] (원문) · [목록에서 빼기] (확인) + Future _broken(VaultEntry e, Object err) async { + if (!mounted) return; + var detail = false; + final drop = await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, set) => AlertDialog( + scrollable: true, + icon: Icon(Icons.report_gmailerrorred_outlined, color: JjColors.danger), + title: Text(trf('"{0}" 을 꺼낼 수 없습니다', [e.name])), + content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(tr('보관함 파일이 바뀌었거나 빠졌습니다 - 이 항목은 꺼낼 수 없습니다 (보관함 폴더를 다른 곳에서 바꿨는지 확인하세요)')), + TextButton(onPressed: () => set(() => detail = !detail), child: Text(detail ? tr('자세히 닫기') : tr('자세히'))), + if (detail) + SelectableText('${e.blob}.jjv · $err', style: TextStyle(fontSize: 12, color: JjColors.textDim, fontFamily: 'Consolas')), + ]), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('목록에서 빼기'))), + FilledButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('닫기'))), + ], + ), + ), + ); + if (drop != true || !mounted) return; + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(tr('목록에서 빼기')), + content: Text(trf('"{0}" 을 보관함 목록에서 뺍니다. 남아 있는 그 내용 파일도 지웁니다 (꺼낼 수 없는 항목이라 잃는 것은 없습니다)', [e.name])), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), + FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('빼기'))), + ], + ), + ); + if (ok != true || _vault == null) return; + await _vault!.remove(e); + if (mounted) setState(() {}); + } + Future _export(VaultEntry e) async { final dir = await vaultPickFolder(); if (dir == null || !mounted) return; @@ -502,7 +555,9 @@ class _VaultPageState extends State with WidgetsBindingObserver { await _vault!.export(e, out, onTemp: j.add, onTempDone: j.remove); _snack(trf('꺼냈습니다: {0}', [vDisplay(out)])); } catch (err) { - _snack(err is VaultAuthException ? tr('이 파일은 바뀌었거나 깨져 풀 수 없습니다') : trf('꺼내지 못했습니다: {0}', [err])); + if (mounted) setState(() => _busy = false); + if (err is VaultAuthException) return _broken(e, err); + _snack(trf('꺼내지 못했습니다: {0}', [err])); } finally { if (mounted) setState(() => _busy = false); } diff --git a/test/thumb_cache_test.dart b/test/thumb_cache_test.dart new file mode 100644 index 0000000..195e9f6 --- /dev/null +++ b/test/thumb_cache_test.dart @@ -0,0 +1,42 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/remote_thumbs.dart'; +import 'package:jj_mkvmaker/core/thumb_cache.dart'; +import 'package:path/path.dart' as p; + +/// 관리자 지시 (10/21, 28 지적): 보관함에 넣은 파일의 썸네일 캐시 (탐색기 동영상 · 빠른 보기 큰 것 · 원격) 와 메모리 기억을 지운다 +void main() { + late Directory tmp; + setUp(() => tmp = Directory.systemTemp.createTempSync('jj_thumbcache_')); + tearDown(() => tmp.deleteSync(recursive: true)); + + test('그 경로 · 크기 · 바뀐 때의 캐시만 지움 (작은 것 · |640 · 원격) · 다른 파일 것은 그대로 · 메모리 기억도 잊음', () async { + final when = DateTime(2026, 10, 21, 14, 5); + File cache(String dir, String key) => File(p.join(tmp.path, dir, '${md5.convert(utf8.encode(key))}.jpg')); + const path = r'C:\사진\비밀.mp4'; + final mine = [ + cache('jj_thumbs', '$path|500|${when.millisecondsSinceEpoch}'), + cache('jj_thumbs', '$path|500|${when.millisecondsSinceEpoch}|640'), + cache('jj_rthumbs', '$path|500|${when.millisecondsSinceEpoch}'), + ]; + final other = cache('jj_thumbs', r'C:\사진\다른.mp4|500|' '${when.millisecondsSinceEpoch}'); + for (final f in [...mine, other]) { + f + ..createSync(recursive: true) + ..writeAsBytesSync([1, 2, 3]); + } + // 원격 썸네일의 키 꼴과 같은지 (같은 이름으로 찾을 수 있어야 함) + expect(RemoteThumbs.keyOf(path, 500, when), thumbCacheKeys(path, 500, when).first); + final forgot = []; + void hook(String x) => forgot.add(x); + thumbMemoryForgetters.add(hook); + addTearDown(() => thumbMemoryForgetters.remove(hook)); + expect(await removeThumbCaches(tmp.path, path, 500, when), 3); + expect(mine.where((f) => f.existsSync()), isEmpty); + expect(other.existsSync(), isTrue); + expect(forgot, [path]); + }); +} diff --git a/test/vault_page_test.dart b/test/vault_page_test.dart index ad5d033..a199731 100644 --- a/test/vault_page_test.dart +++ b/test/vault_page_test.dart @@ -9,6 +9,7 @@ import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart'; import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/services/secret_store.dart'; import 'package:jj_mkvmaker/core/shizuku.dart'; +import 'package:jj_mkvmaker/core/vault_store.dart'; import 'package:jj_mkvmaker/core/vault_view.dart'; import 'package:image/image.dart' as img; import 'package:jj_mkvmaker/ui/explorer_page.dart'; @@ -243,6 +244,37 @@ void main() { expect(Directory(root.path).listSync().map((e) => p.basename(e.path)), ['other']); }); + testWidgets('내용 파일이 빠진 항목 (예전 목록으로 되돌림 등): 꺼내기 → 이유 · [자세히] 원문 · [목록에서 빼기] (확인) - 28 지적 · 관리자 지시 10/21', (t) async { + t.view.physicalSize = const Size(900, 1400); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + final dir = Directory(p.join(tmp.path, 'vault')); + final v = (await t.runAsync(() => Vault.create(dir, 'pw', iterations: 1000)))!; + final f = File(p.join(tmp.path, '사라질.pdf'))..writeAsBytesSync(List.filled(100, 3)); + final e = (await t.runAsync(() => v.add(f.path)))!; + File(p.join(dir.path, '${e.blob}.jjv')).deleteSync(); + await t.pumpWidget(MaterialApp(home: VaultPage(c: ctl()))); + await until(t, () => find.widgetWithText(FilledButton, '열기').evaluate().isNotEmpty); + await t.enterText(find.byType(TextField), 'pw'); + await t.tap(find.widgetWithText(FilledButton, '열기')); + await until(t, () => find.text('사라질.pdf').evaluate().isNotEmpty); + await menu(t, '사라질.pdf', '꺼내기'); + await until(t, () => find.textContaining('보관함 파일이 바뀌었거나 빠졌습니다').evaluate().isNotEmpty); + expect(find.textContaining('보관함 폴더를 다른 곳에서 바꿨는지 확인하세요'), findsOneWidget); + expect(find.textContaining('${e.blob}.jjv'), findsNothing, reason: '원문은 [자세히] 를 눌러야'); + await t.tap(find.text('자세히')); + await t.pump(); + expect(find.textContaining('${e.blob}.jjv'), findsOneWidget); + expect(Directory(p.join(tmp.path, 'out')).listSync(), isEmpty, reason: '꺼낸 파일 · .jjvtmp 없음'); + await t.tap(find.text('목록에서 빼기')); + await settle(t); + await t.tap(find.widgetWithText(FilledButton, '빼기')); + await until(t, () => find.text('보관함이 비어 있습니다').evaluate().isNotEmpty); + expect(find.text('사라질.pdf'), findsNothing); + await until(t, () => !dir.listSync().any((x) => x.path.endsWith('.tmp'))); + await t.pumpWidget(const SizedBox()); + }); + testWidgets('보기: 그림은 메모리에서 (파일로 풀지 않음) · 동영상은 앱 전용 폴더에 잠깐 · 잠그면 열린 보기를 닫고 지움 → 남은 것 0', (t) async { t.view.physicalSize = const Size(900, 1400); t.view.devicePixelRatio = 1;