diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 3194866..1590f22 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -2073,6 +2073,7 @@ "원본에 없는 것을 대상에서 지우기": "Delete from target what is not in the source", "원본에는 아무것도 없는데 대상에는 {0}개가 있습니다. SD 카드가 빠졌거나 네트워크 · 권한 문제일 수 있습니다. 일부러 원본을 비웠다면 지울 목록을 보고 [그래도 맞추기] 를 누르세요.": "The source is empty but the target has {0} items. The SD card may have been removed, or there may be a network or permission problem. If you emptied the source on purpose, review the list and press [Sync anyway].", "원본으로": "Back to the original", + "원본은 휴지통으로 갑니다 - 휴지통에 평문 (암호화하지 않은 원본) 이 남습니다": "Originals go to the trash - the unencrypted originals remain in the trash", "원본을 읽을 수 없어 멈춤": "Stopped: can't read the source", "원본을 읽을 수 없어 멈췄습니다": "Stopped because the source can't be read", "원본을 지우는 실행이라, 옮길 목록이 나온 뒤에 실행할 수 있습니다.": "This run removes source files, so it can run once the list of files to move is shown.", @@ -2599,6 +2600,7 @@ "지난 업데이트가 남긴 임시 파일을 지움: {0}": "Removed temporary files left by the last update: {0}", "지난 작업 기록": "Old job logs", "지난번에 남은 문서 보기 임시 폴더를 지움: {0}개": "Removed {0} leftover document-view temp folders", + "지난번에 남은 보관함 보기 임시 폴더를 지움: {0}개": "Removed {0} leftover vault view temp folders", "지문 확인하고 연결": "Check fingerprint and connect", "지우고 맞추기": "Delete and sync", "지우고 초기화": "Delete and reset", @@ -3033,10 +3035,12 @@ "휴지통에서 지운 것은 되살릴 수 없습니다.": "Items deleted from the trash can't be restored.", "휴지통에서 찾지 못했습니다 (이미 비웠거나 되돌림)": "Not found in the Recycle Bin (already emptied or restored)", "휴지통으로": "To Recycle Bin", + "휴지통으로 (처음 값)": "To the trash (default)", "휴지통으로 보내기": "Move to Recycle Bin", "휴지통으로 옮기지 못했습니다: {0} ({1})": "Couldn't move to the trash: {0} ({1})", "휴지통을 거치지 않고 영구 삭제": "Delete permanently without the trash", "휴지통을 거치지 않고 영구 삭제 (Shift+Delete)": "Delete permanently without the Recycle Bin (Shift+Delete)", + "휴지통을 거치지 않습니다 - 지우기 전에 확인 창을 한 번 더 띄웁니다": "Skips the trash - asks for confirmation once more before deleting", "휴지통이 꺼져 있습니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "The trash is off (Settings > File explorer > Keep in trash).", "휴지통이 꺼져 있습니다 - 지우면 바로 영구 삭제됩니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "The trash is off - deleting removes items permanently right away (Settings > File explorer > Keep in trash).", "휴지통이 비어 있습니다": "The trash is empty", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index 11623a3..179d48d 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -2073,6 +2073,7 @@ "원본에 없는 것을 대상에서 지우기": "元にないものを対象から削除", "원본에는 아무것도 없는데 대상에는 {0}개가 있습니다. SD 카드가 빠졌거나 네트워크 · 권한 문제일 수 있습니다. 일부러 원본을 비웠다면 지울 목록을 보고 [그래도 맞추기] 를 누르세요.": "元には何もありませんが、対象には {0} 個あります。SD カードが抜けたか、ネットワーク・権限の問題かもしれません。意図的に元を空にした場合は、削除する一覧を見て [それでも同期] を押してください。", "원본으로": "元の画像で", + "원본은 휴지통으로 갑니다 - 휴지통에 평문 (암호화하지 않은 원본) 이 남습니다": "元のファイルはごみ箱に移ります - ごみ箱に暗号化されていない元のファイルが残ります", "원본을 읽을 수 없어 멈춤": "元を読めないため停止", "원본을 읽을 수 없어 멈췄습니다": "元を読めないため停止しました", "원본을 지우는 실행이라, 옮길 목록이 나온 뒤에 실행할 수 있습니다.": "元のファイルを削除する実行のため、移動する一覧が表示されてから実行できます。", @@ -2599,6 +2600,7 @@ "지난 업데이트가 남긴 임시 파일을 지움: {0}": "前回のアップデートが残した一時ファイルを削除: {0}", "지난 작업 기록": "過去の作業記録", "지난번에 남은 문서 보기 임시 폴더를 지움: {0}개": "前回残った文書表示の一時フォルダーを削除: {0} 個", + "지난번에 남은 보관함 보기 임시 폴더를 지움: {0}개": "前回残った保管庫表示の一時フォルダーを削除: {0} 個", "지문 확인하고 연결": "フィンガープリントを確認して接続", "지우고 맞추기": "削除して同期", "지우고 초기화": "削除して初期化", @@ -3033,10 +3035,12 @@ "휴지통에서 지운 것은 되살릴 수 없습니다.": "ゴミ箱から削除したものは元に戻せません。", "휴지통에서 찾지 못했습니다 (이미 비웠거나 되돌림)": "ごみ箱に見つかりません (空にしたか元に戻した)", "휴지통으로": "ごみ箱へ", + "휴지통으로 (처음 값)": "ごみ箱へ (既定)", "휴지통으로 보내기": "ごみ箱に移動", "휴지통으로 옮기지 못했습니다: {0} ({1})": "ゴミ箱に移せませんでした: {0} ({1})", "휴지통을 거치지 않고 영구 삭제": "ゴミ箱を使わずに完全に削除", "휴지통을 거치지 않고 영구 삭제 (Shift+Delete)": "ごみ箱を使わずに完全に削除 (Shift+Delete)", + "휴지통을 거치지 않습니다 - 지우기 전에 확인 창을 한 번 더 띄웁니다": "ごみ箱を経由しません - 削除前にもう一度確認します", "휴지통이 꺼져 있습니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "ゴミ箱はオフです (設定 > ファイルエクスプローラー > ゴミ箱の保管)。", "휴지통이 꺼져 있습니다 - 지우면 바로 영구 삭제됩니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "ゴミ箱はオフです - 削除するとすぐに完全削除されます (設定 > ファイルエクスプローラー > ゴミ箱の保管)。", "휴지통이 비어 있습니다": "ゴミ箱は空です", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index e904eae..8c7b3a5 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -2073,6 +2073,7 @@ "원본에 없는 것을 대상에서 지우기": "从目标中删除源中没有的内容", "원본에는 아무것도 없는데 대상에는 {0}개가 있습니다. SD 카드가 빠졌거나 네트워크 · 권한 문제일 수 있습니다. 일부러 원본을 비웠다면 지울 목록을 보고 [그래도 맞추기] 를 누르세요.": "源中没有任何内容,但目标中有 {0} 个项目。可能是 SD 卡被拔出,或网络 · 权限有问题。如果你有意清空了源,请查看要删除的列表并按 [仍然同步]。", "원본으로": "回到原图", + "원본은 휴지통으로 갑니다 - 휴지통에 평문 (암호화하지 않은 원본) 이 남습니다": "原文件将进入回收站 - 回收站中会留下未加密的原文件", "원본을 읽을 수 없어 멈춤": "无法读取源,已停止", "원본을 읽을 수 없어 멈췄습니다": "无法读取源,已停止", "원본을 지우는 실행이라, 옮길 목록이 나온 뒤에 실행할 수 있습니다.": "此操作会删除源文件,需在显示要移动的列表后才能执行。", @@ -2599,6 +2600,7 @@ "지난 업데이트가 남긴 임시 파일을 지움: {0}": "已删除上次更新留下的临时文件: {0}", "지난 작업 기록": "旧任务记录", "지난번에 남은 문서 보기 임시 폴더를 지움: {0}개": "已删除上次留下的文档查看临时文件夹: {0} 个", + "지난번에 남은 보관함 보기 임시 폴더를 지움: {0}개": "已删除上次残留的保管库查看临时文件夹: {0} 个", "지문 확인하고 연결": "确认指纹并连接", "지우고 맞추기": "删除并同步", "지우고 초기화": "删除并重置", @@ -3033,10 +3035,12 @@ "휴지통에서 지운 것은 되살릴 수 없습니다.": "从回收站删除的项目无法还原。", "휴지통에서 찾지 못했습니다 (이미 비웠거나 되돌림)": "在回收站中找不到 (已清空或已恢复)", "휴지통으로": "移到回收站", + "휴지통으로 (처음 값)": "移到回收站 (默认)", "휴지통으로 보내기": "移到回收站", "휴지통으로 옮기지 못했습니다: {0} ({1})": "无法移到回收站: {0} ({1})", "휴지통을 거치지 않고 영구 삭제": "不经回收站永久删除", "휴지통을 거치지 않고 영구 삭제 (Shift+Delete)": "不经过回收站永久删除 (Shift+Delete)", + "휴지통을 거치지 않습니다 - 지우기 전에 확인 창을 한 번 더 띄웁니다": "不经过回收站 - 删除前会再次确认", "휴지통이 꺼져 있습니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "回收站已关闭 (设置 > 文件资源管理器 > 回收站保留)。", "휴지통이 꺼져 있습니다 - 지우면 바로 영구 삭제됩니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "回收站已关闭 - 删除会立即永久删除 (设置 > 文件资源管理器 > 回收站保留)。", "휴지통이 비어 있습니다": "回收站为空", diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index a153aa4..6e50744 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -33,6 +33,7 @@ import '../services/system_usage.dart'; import 'settings.dart'; import 'cleanup.dart' show removeUpdateLeftovers; import '../core/hwp_source.dart' show removeStaleHwpWork; +import '../core/vault_view.dart' show removeStaleVaultViews, vaultViewRootOf; import '../l10n/tr.dart'; import '../core/remote_idle.dart'; @@ -221,6 +222,11 @@ class AppController extends ChangeNotifier { final hwp = removeStaleHwpWork(await services.storage.tempDirectory()); if (hwp.isNotEmpty) _log(trf('지난번에 남은 문서 보기 임시 폴더를 지움: {0}개', [hwp.length])); } catch (_) {} + // 관리자 지시 (10/21): 강제로 꺼져 남은 암호 보관함 보기 폴더 (잠깐 푼 평문) + try { + final vault = removeStaleVaultViews((await vaultViewRootOf()).path); + if (vault.isNotEmpty) _log(trf('지난번에 남은 보관함 보기 임시 폴더를 지움: {0}개', [vault.length])); + } catch (_) {} }()); ffmpegVersion = await _tool.version(); if (ffmpegVersion == null) { diff --git a/lib/core/vault_store.dart b/lib/core/vault_store.dart index 634808b..5dc08d0 100644 --- a/lib/core/vault_store.dart +++ b/lib/core/vault_store.dart @@ -224,6 +224,13 @@ class Vault { await tmp.rename(outPath); } + /// 메모리로 푼다 (관리자 지시 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(); + return VaultCrypto.decryptAll(_key, data); + } + /// 보관함에서 지운다 (목록 먼저 - 그 뒤 내용 파일) Future remove(VaultEntry e) async { _entries.removeWhere((x) => x.blob == e.blob); diff --git a/lib/core/vault_view.dart b/lib/core/vault_view.dart new file mode 100644 index 0000000..bf47d21 --- /dev/null +++ b/lib/core/vault_view.dart @@ -0,0 +1,30 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +/// 암호 보관함 [보기] 에서 잠깐 푼 파일을 두는 곳 (관리자 지시 10/21): 공유 폴더 (Download · 시스템 TEMP) 가 아니라 +/// 앱 전용 - Android getCacheDir (다른 앱이 못 봄), Windows 앱 데이터 폴더의 temp. 시험에서 바꾼다 +Future Function() vaultViewRootOf = () async { + if (Platform.isAndroid) return getTemporaryDirectory(); + return Directory(p.join((await getApplicationSupportDirectory()).path, 'temp')); +}; + +/// 푼 파일을 두는 폴더 이름 앞 (실행마다 새 폴더) +const vaultViewPrefix = 'jj_vault_view_'; + +/// 앱이 강제로 꺼져 남은 보기 폴더를 지운다 (앱을 켤 때 - 그때는 보관함이 열려 있지 않음). 지운 이름 +List removeStaleVaultViews(String root) { + final removed = []; + try { + for (final e in Directory(root).listSync(followLinks: false)) { + final name = p.basename(e.path); + if (e is! Directory || !name.startsWith(vaultViewPrefix)) continue; + try { + e.deleteSync(recursive: true); + removed.add(name); + } catch (_) {} + } + } catch (_) {} + return removed; +} diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 3b74caf..c60b1e4 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -2196,7 +2196,11 @@ class _ExplorerPageState extends State { case 'sqlite': await openSqliteViewer(context, c, e.path); case 'vaultAdd': - await openVault(context, c, add: targets, deleteOriginals: (paths) => _delete(pane, paths)); + await openVault(context, c, add: targets, deleteOriginals: (paths, {required permanent}) { + // 관리자 지시 (10/21): [영구 삭제] 를 고르면 휴지통을 거치지 않음 (Shift+Delete 와 같은 길 - 탐색기의 영구 삭제 확인 창을 한 번 더) + _shiftDelete = permanent; + return _delete(pane, paths); + }); await _refreshAll([vDirname(e.path)]); case 'openWith': final local = await _fetch(e.path); diff --git a/lib/ui/vault_page.dart b/lib/ui/vault_page.dart index a845eb1..cb1a8b8 100644 --- a/lib/ui/vault_page.dart +++ b/lib/ui/vault_page.dart @@ -16,6 +16,7 @@ import '../core/reader_sources.dart'; 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 vDisplay; import '../l10n/tr.dart'; import 'player_page.dart'; @@ -24,7 +25,7 @@ import 'theme.dart'; /// X-plore 비교 5-2 암호 보관함 화면 (사용자 결정 10/21, 관리자 결정 - 설계안 위키 프로젝트/암호 보관함 · 루트 설계안 (10-21)) Future openVault(BuildContext context, AppController c, - {List add = const [], Future Function(List paths)? deleteOriginals}) => + {List add = const [], Future Function(List paths, {required bool permanent})? deleteOriginals}) => Navigator.of(context).push(MaterialPageRoute(builder: (_) => VaultPage(c: c, add: add, deleteOriginals: deleteOriginals))); /// 보관함 폴더 (Android: Download/JJ_MKVMaker/vault · Windows: 문서\JJ_MKVMaker\vault). 시험에서 바꿈 @@ -48,8 +49,8 @@ class VaultPage extends StatefulWidget { /// 열면서 넣을 파일 (탐색기 [보관함에 넣기]) final List add; - /// [원본 지우기 (이동)] 일 때 탐색기의 지우기 (확인 · 휴지통) - final Future Function(List paths)? deleteOriginals; + /// [원본 지우기 (이동)] 일 때 탐색기의 지우기 (확인 창 · [permanent] 가 아니면 휴지통) + final Future Function(List paths, {required bool permanent})? deleteOriginals; const VaultPage({super.key, required this.c, this.add = const [], this.deleteOriginals}); @override @@ -83,7 +84,7 @@ class _VaultPageState extends State with WidgetsBindingObserver { @override void dispose() { WidgetsBinding.instance.removeObserver(this); - _lock(); + _lock(closeViews: false); _pw.dispose(); _pw2.dispose(); super.dispose(); @@ -103,19 +104,43 @@ class _VaultPageState extends State with WidgetsBindingObserver { } } - void _lock() { + /// 그림은 메모리에서 바로 (파일로 풀지 않음) + static const _imageMax = 256 * 1024 * 1024; + + /// 이 화면의 길 (잠글 때 위에 열린 보기 화면을 닫으려고) + ModalRoute? _route; + + /// 잠금: 열쇠를 버리고, 위에 열린 보기 (그림 · 플레이어) 를 닫고, 잠깐 푼 파일을 지운다 (관리자 지시 10/21) + void _lock({bool closeViews = true}) { _vault = null; if (_stage == _Stage.open) _stage = _Stage.locked; + final r = _route; + if (closeViews && r != null && mounted && !r.isCurrent) Navigator.of(context).popUntil((x) => x == r); unawaited(_clearViewDir()); } - Future _viewDir() async => Directory(p.join(await c.services.storage.tempDirectory(), 'jj_vault_view')); + /// 이번에 연 동안 잠깐 푼 파일을 두는 앱 전용 폴더 (jj_vault_view_…) + Directory? _session; + + Future _viewDir() async { + final d = _session ??= Directory(p.join((await vaultViewRootOf()).path, '$vaultViewPrefix${DateTime.now().microsecondsSinceEpoch}')); + await d.create(recursive: true); + return d; + } Future _clearViewDir() async { - try { - final d = await _viewDir(); - if (await d.exists()) await d.delete(recursive: true); - } catch (_) {} + final d = _session; + _session = null; + if (d == null) return; + // Windows 는 막 쓴 파일을 백신 · 색인이 잠깐 잡고 있어 지우기가 실패할 수 있다 - 몇 번 더 (남으면 다음에 켤 때 지움) + for (var i = 0; i < 10; i++) { + try { + if (await d.exists()) await d.delete(recursive: true); + return; + } catch (_) { + await Future.delayed(const Duration(milliseconds: 200)); + } + } } Future _check() async { @@ -244,6 +269,8 @@ class _VaultPageState extends State with WidgetsBindingObserver { if (paths.isEmpty || _vault == null) return; _pendingAdd = []; var move = false; + // 관리자 지시 (10/21): 휴지통으로 보내면 평문 원본이 휴지통에 남는다 - 알리고 고르게 (처음 값 휴지통: 실수로 잃지 않게) + var permanent = false; final go = await showDialog( context: context, builder: (ctx) => StatefulBuilder( @@ -262,6 +289,23 @@ class _VaultPageState extends State with WidgetsBindingObserver { title: Text(tr('원본 지우기 (이동)')), subtitle: Text(tr('다 넣고 확인한 뒤에 탐색기의 지우기 (확인 · 휴지통) 로 지웁니다. 끄면 원본은 그대로 (복사)')), ), + if (move) ...[ + Text(tr('원본은 휴지통으로 갑니다 - 휴지통에 평문 (암호화하지 않은 원본) 이 남습니다'), + style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)), + RadioGroup( + groupValue: permanent, + onChanged: (v) => set(() => permanent = v ?? false), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + RadioListTile(dense: true, contentPadding: EdgeInsets.zero, value: false, title: Text(tr('휴지통으로 (처음 값)'))), + RadioListTile( + dense: true, + contentPadding: EdgeInsets.zero, + value: true, + title: Text(tr('영구 삭제')), + subtitle: Text(tr('휴지통을 거치지 않습니다 - 지우기 전에 확인 창을 한 번 더 띄웁니다'))), + ]), + ), + ], ]), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), @@ -284,28 +328,42 @@ class _VaultPageState extends State with WidgetsBindingObserver { if (mounted) setState(() => _busy = false); } if (done.isNotEmpty) _snack(trf('{0}개를 보관함에 넣었습니다', [done.length])); - if (move && done.isNotEmpty && widget.deleteOriginals != null) await widget.deleteOriginals!(done); + if (move && done.isNotEmpty && widget.deleteOriginals != null) await widget.deleteOriginals!(done, permanent: permanent); } Future _view(VaultEntry e) async { + final ext = p.extension(e.name).replaceFirst('.', '').toLowerCase(); + // 그림: 메모리에서 바로 (파일로 풀지 않음 - 관리자 지시 10/21) + if (c.settings.imageExts.contains(ext) && e.size <= _imageMax) { + try { + setState(() => _busy = true); + final bytes = await _vault!.readBytes(e, maxBytes: _imageMax); + if (mounted) setState(() => _busy = false); + if (!mounted) return; + await openReader(context, c, _VaultImageSource(e.name, bytes)); + } catch (err) { + if (mounted) setState(() => _busy = false); + _snack(err is VaultAuthException ? tr('이 파일은 바뀌었거나 깨져 풀 수 없습니다') : trf('열 수 없습니다: {0}', [err])); + } + return; + } + if (!isVideoFile(e.name) && !isAudioFile(e.name)) { + _snack(tr('이 종류는 앱 안에서 볼 수 없습니다 - [꺼내기] 로 꺼내서 보세요')); + return; + } try { + // 동영상 · 음악: 앱 전용 폴더 (jj_vault_view_…) 에 잠깐 풀고, 닫으면 · 잠그면 · 다음에 켤 때 지운다 final d = await _viewDir(); - await d.create(recursive: true); final out = p.join(d.path, 'v_${DateTime.now().microsecondsSinceEpoch}${p.extension(e.name)}'); setState(() => _busy = true); await _vault!.export(e, out); if (mounted) setState(() => _busy = false); if (!mounted) return; - final ext = p.extension(e.name).replaceFirst('.', '').toLowerCase(); - if (c.settings.imageExts.contains(ext)) { - await openReader(context, c, ImageFilesSource([out], tempDir: d.path, title: e.name)); - } else if (isVideoFile(out) || isAudioFile(out)) { + { final create = c.services.createMediaPlayer; if (create != null) { await Navigator.of(context).push(MaterialPageRoute(builder: (_) => PlayerPage(c: c, player: create(), files: [out]))); } - } else { - _snack(tr('이 종류는 앱 안에서 볼 수 없습니다 - [꺼내기] 로 꺼내서 보세요')); } // 본 뒤 풀어 둔 것은 지운다 await File(out).delete().catchError((_) => File(out)); @@ -432,7 +490,12 @@ class _VaultPageState extends State with WidgetsBindingObserver { } @override - Widget build(BuildContext context) => Scaffold( + Widget build(BuildContext context) { + _route ??= ModalRoute.of(context); + return _scaffold(); + } + + Widget _scaffold() => Scaffold( appBar: AppBar( title: Text(tr('암호 보관함')), actions: [ @@ -585,3 +648,19 @@ class _VaultPageState extends State with WidgetsBindingObserver { ]); } } + +/// 보관함 그림을 메모리에서 바로 보는 보기 화면 원본 (파일로 풀지 않음) +class _VaultImageSource extends ReaderSource { + final String name; + final Uint8List bytes; + _VaultImageSource(this.name, this.bytes); + @override + String get title => name; + @override + int get length => 1; + @override + String pageName(int i) => name; + @override + Future load(int i, {required int maxWidth}) async => + ReaderImage(provider: ResizeImage.resizeIfNeeded(maxWidth, null, MemoryImage(bytes))); +} diff --git a/test/flutter_test_config.dart b/test/flutter_test_config.dart index 68efdd5..b894289 100644 --- a/test/flutter_test_config.dart +++ b/test/flutter_test_config.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/vault_view.dart'; import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; /// 모든 단위 시험 파일 앞에 돈다 (flutter_test 의 flutter_test_config). @@ -10,6 +11,8 @@ import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; Future testExecutable(FutureOr Function() testMain) async { final root = Directory.systemTemp.createTempSync('jj_store_'); DesktopStorageService.tempRoot = root.path; + // 암호 보관함 보기 임시 폴더도 (기본은 실제 앱 데이터 폴더 - 시험이 건드리지 않게) + vaultViewRootOf = () async => Directory('${root.path}${Platform.pathSeparator}vault_view'); tearDownAll(() { try { root.deleteSync(recursive: true); diff --git a/test/vault_page_test.dart b/test/vault_page_test.dart index 5cdf2ef..f8f8545 100644 --- a/test/vault_page_test.dart +++ b/test/vault_page_test.dart @@ -8,7 +8,10 @@ import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart'; 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/vault_view.dart'; +import 'package:image/image.dart' as img; import 'package:jj_mkvmaker/ui/explorer_page.dart'; +import 'package:jj_mkvmaker/ui/reader_page.dart' show ReaderPage; import 'package:jj_mkvmaker/ui/vault_page.dart'; import 'package:path/path.dart' as p; @@ -41,7 +44,7 @@ void main() { Future until(WidgetTester t, bool Function() ok, {int rounds = 200}) async { for (var i = 0; i < rounds && !ok(); i++) { await t.runAsync(() => Future.delayed(const Duration(milliseconds: 20))); - await t.pump(); + await t.pump(const Duration(milliseconds: 20)); } // 닫히는 창 · 메뉴의 막 (barrier) 이 사라질 때까지 for (var i = 0; i < 8; i++) { @@ -71,9 +74,13 @@ void main() { addTearDown(t.view.reset); final src = File(p.join(tmp.path, '비밀 문서.pdf'))..writeAsBytesSync(List.generate(3000, (i) => i % 256)); final deleted = >[]; + final permanents = []; final c = ctl(); await t.pumpWidget(MaterialApp( - home: VaultPage(c: c, add: [src.path], deleteOriginals: (paths) async => deleted.add(paths)))); + home: VaultPage(c: c, add: [src.path], deleteOriginals: (paths, {required permanent}) async { + deleted.add(paths); + permanents.add(permanent); + }))); await until(t, () => find.text('새 보관함 만들기').evaluate().isNotEmpty); expect(find.textContaining('다른 앱도 이 폴더의 암호화된 파일을 볼 수 있습니다'), findsOneWidget); expect(find.textContaining('이 폴더를 지우면 보관함도 사라집니다'), findsOneWidget); @@ -91,9 +98,15 @@ void main() { expect(t.widget(find.byType(CheckboxListTile)).value, isFalse, reason: '처음은 복사'); await t.tap(find.text('원본 지우기 (이동)')); await t.pump(); + // 관리자 지시 (10/21): 휴지통에 평문이 남는다는 알림 · 처음 값 휴지통 · 영구 삭제를 고를 수 있음 + expect(find.textContaining('휴지통에 평문'), findsOneWidget); + expect(find.text('휴지통으로 (처음 값)'), findsOneWidget); + await t.tap(find.text('영구 삭제')); + await t.pump(); await t.tap(find.widgetWithText(FilledButton, '넣기')); await until(t, () => deleted.isNotEmpty); expect(deleted.single, [src.path], reason: '다 넣은 뒤 탐색기의 지우기로'); + expect(permanents.single, isTrue, reason: '영구 삭제를 골랐음 (탐색기가 영구 삭제 확인 창을 한 번 더)'); expect(find.text('비밀 문서.pdf'), findsOneWidget); expect(Directory(p.join(tmp.path, 'vault')).listSync().map((f) => p.basename(f.path)).join(), isNot(contains('비밀'))); // 잠금 → 틀린 비밀번호 → 맞는 비밀번호 @@ -116,6 +129,8 @@ void main() { await t.tap(find.widgetWithText(FilledButton, '지우기')); await until(t, () => find.text('보관함이 비어 있습니다').evaluate().isNotEmpty); expect(find.text('보관함이 비어 있습니다'), findsOneWidget); + // 목록 쓰기 (index.jjv.tmp → index.jjv) 가 끝날 때까지 (화면은 먼저 바뀔 수 있다) + await until(t, () => !Directory(p.join(tmp.path, 'vault')).listSync().any((f) => f.path.endsWith('.tmp'))); await t.pumpWidget(const SizedBox()); }); @@ -175,6 +190,8 @@ void main() { await t.tap(find.widgetWithText(FilledButton, '열기')); await until(t, () => find.text('보관함이 비어 있습니다').evaluate().isNotEmpty); expect(find.text('보관함이 비어 있습니다'), findsOneWidget); + // 목록 쓰기 (index.jjv.tmp → index.jjv) 가 끝날 때까지 (화면은 먼저 바뀔 수 있다) + await until(t, () => !Directory(p.join(tmp.path, 'vault')).listSync().any((f) => f.path.endsWith('.tmp'))); await t.pumpWidget(const SizedBox()); }); testWidgets('파일 탐색기: [보관함에 넣기] 는 보관함을 켰을 때 · 이 기기의 파일에만 (폴더는 2단계)', (t) async { @@ -207,4 +224,60 @@ void main() { await t.pump(const Duration(seconds: 1)); } }); + test('앱을 켤 때 남은 보기 폴더 (jj_vault_view_…) 만 지움', () { + final root = Directory(p.join(tmp.path, 'root'))..createSync(); + Directory(p.join(root.path, 'jj_vault_view_123')).createSync(); + File(p.join(root.path, 'jj_vault_view_123', 'v.mp4')).writeAsStringSync('plain'); + Directory(p.join(root.path, 'other')).createSync(); + expect(removeStaleVaultViews(root.path), ['jj_vault_view_123']); + expect(Directory(root.path).listSync().map((e) => p.basename(e.path)), ['other']); + }); + + testWidgets('보기: 그림은 메모리에서 (파일로 풀지 않음) · 동영상은 앱 전용 폴더에 잠깐 · 잠그면 열린 보기를 닫고 지움 → 남은 것 0', (t) async { + t.view.physicalSize = const Size(900, 1400); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + final viewRoot = Directory(p.join(tmp.path, 'viewroot')); + final wasRoot = vaultViewRootOf; + vaultViewRootOf = () async => viewRoot; + addTearDown(() => vaultViewRootOf = wasRoot); + List leftovers() => + viewRoot.existsSync() ? viewRoot.listSync(recursive: true).map((e) => e.path).toList() : const []; + final photo = File(p.join(tmp.path, 'photo.png'))..writeAsBytesSync(img.encodePng(img.Image(width: 20, height: 10))); + final clip = File(p.join(tmp.path, 'clip.mp4'))..writeAsBytesSync(List.filled(500, 1)); + final c = ctl()..settings.vaultAutoLockSeconds = 0; + await t.pumpWidget(MaterialApp(home: VaultPage(c: c, add: [photo.path, clip.path]))); + await until(t, () => find.text('새 보관함 만들기').evaluate().isNotEmpty); + await t.enterText(find.byType(TextField).at(0), 'pw'); + await t.enterText(find.byType(TextField).at(1), 'pw'); + await t.tap(find.text('비밀번호를 잊으면 되살릴 수 없음을 압니다')); + await t.pump(); + await t.tap(find.widgetWithText(FilledButton, '만들기')); + await until(t, () => find.widgetWithText(FilledButton, '넣기').evaluate().isNotEmpty); + await t.tap(find.widgetWithText(FilledButton, '넣기')); + await until(t, () => find.text('photo.png').evaluate().isNotEmpty && find.text('clip.mp4').evaluate().isNotEmpty); + // 동영상 보기 (시험에는 플레이어가 없어 바로 돌아옴) → 푼 파일은 지워짐 + await t.tap(find.text('clip.mp4')); + await until(t, () => find.byType(LinearProgressIndicator).evaluate().isEmpty); + expect(leftovers().where((x) => x.endsWith('.mp4')), isEmpty, reason: '보기를 닫으면 지움'); + // 그림 보기: 파일로 풀지 않음 + await t.tap(find.text('photo.png')); + await until(t, () => find.byType(ReaderPage).evaluate().isNotEmpty); + expect(find.byType(ReaderPage), findsOneWidget); + expect(leftovers().where((x) => x.endsWith('.png')), isEmpty, reason: '그림은 메모리에서'); + // 앱이 뒤로 갔다 돌아옴 (자동 잠금 0초) → 보기 닫고 잠금 · 남은 것 0 + t.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + t.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden); + t.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + await t.pump(); + t.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden); + t.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive); + t.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await until(t, () => find.byType(ReaderPage).evaluate().isEmpty); + expect(find.byType(ReaderPage), findsNothing, reason: '잠그면 열린 보기를 닫음'); + expect(find.widgetWithText(FilledButton, '열기'), findsOneWidget, reason: '잠금 화면'); + await until(t, () => leftovers().isEmpty); + expect(leftovers(), isEmpty, reason: '잠그면 잠깐 푼 폴더도 지움'); + await t.pumpWidget(const SizedBox()); + }); }