WIP 암호 보관함: 평문이 남을 수 있는 두 곳 (관리자 지시 10/21) - ① 넣기의 [원본 지우기 (이동)]: "원본은 휴지통으로 갑니다 - 휴지통에 평문이 남습니다" 를 보이고 [휴지통으로 (처음 값)] / [영구 삭제] 를 고름, 영구 삭제는 탐색기의 영구 삭제 길 (Shift+Delete 와 같은 확인 창을 한 번 더) ② [보기]: 그림은 메모리에서 바로 (파일로 풀지 않음, ReaderSource 를 MemoryImage 로), 동영상 · 음악만 앱 전용 폴더 (Android getCacheDir · Windows 앱 데이터의 temp) 아래 jj_vault_view_… 에 잠깐 - 닫으면 지움, 자동 잠금 · [잠그기] 때 열린 보기를 닫고 지움, 앱이 강제로 꺼져 남은 것은 다음에 켤 때 지움 (jj_hwp_ 정리처럼), Windows 에서 막 쓴 파일을 다른 프로그램이 잠깐 잡아 지우기가 실패하는 것은 몇 번 더 시도 · 시험: 영구 삭제 고르기 · 그림은 파일 0 · 동영상 보기 뒤 0 · 잠그면 보기 닫힘 · 남은 것 0 · 켤 때 정리 (시험의 보기 폴더는 파일마다 임시로 - flutter_test_config) (검토 대기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
xplore_p3b
RyuJongJin 2026-10-21 12:10:00 +09:00
parent 286e6b4698
commit dc4bae5329
10 changed files with 236 additions and 22 deletions

View File

@ -2073,6 +2073,7 @@
"원본에 없는 것을 대상에서 지우기": "Delete from target what is not in the source", "원본에 없는 것을 대상에서 지우기": "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].", "원본에는 아무것도 없는데 대상에는 {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", "원본으로": "Back to the original",
"원본은 휴지통으로 갑니다 - 휴지통에 평문 (암호화하지 않은 원본) 이 남습니다": "Originals go to the trash - the unencrypted originals remain in the trash",
"원본을 읽을 수 없어 멈춤": "Stopped: can't read the source", "원본을 읽을 수 없어 멈춤": "Stopped: can't read the source",
"원본을 읽을 수 없어 멈췄습니다": "Stopped because the source can't be read", "원본을 읽을 수 없어 멈췄습니다": "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.", "원본을 지우는 실행이라, 옮길 목록이 나온 뒤에 실행할 수 있습니다.": "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}", "지난 업데이트가 남긴 임시 파일을 지움: {0}": "Removed temporary files left by the last update: {0}",
"지난 작업 기록": "Old job logs", "지난 작업 기록": "Old job logs",
"지난번에 남은 문서 보기 임시 폴더를 지움: {0}개": "Removed {0} leftover document-view temp folders", "지난번에 남은 문서 보기 임시 폴더를 지움: {0}개": "Removed {0} leftover document-view temp folders",
"지난번에 남은 보관함 보기 임시 폴더를 지움: {0}개": "Removed {0} leftover vault view temp folders",
"지문 확인하고 연결": "Check fingerprint and connect", "지문 확인하고 연결": "Check fingerprint and connect",
"지우고 맞추기": "Delete and sync", "지우고 맞추기": "Delete and sync",
"지우고 초기화": "Delete and reset", "지우고 초기화": "Delete and reset",
@ -3033,10 +3035,12 @@
"휴지통에서 지운 것은 되살릴 수 없습니다.": "Items deleted from the trash can't be restored.", "휴지통에서 지운 것은 되살릴 수 없습니다.": "Items deleted from the trash can't be restored.",
"휴지통에서 찾지 못했습니다 (이미 비웠거나 되돌림)": "Not found in the Recycle Bin (already emptied or restored)", "휴지통에서 찾지 못했습니다 (이미 비웠거나 되돌림)": "Not found in the Recycle Bin (already emptied or restored)",
"휴지통으로": "To Recycle Bin", "휴지통으로": "To Recycle Bin",
"휴지통으로 (처음 값)": "To the trash (default)",
"휴지통으로 보내기": "Move to Recycle Bin", "휴지통으로 보내기": "Move to Recycle Bin",
"휴지통으로 옮기지 못했습니다: {0} ({1})": "Couldn't move to the trash: {0} ({1})", "휴지통으로 옮기지 못했습니다: {0} ({1})": "Couldn't move to the trash: {0} ({1})",
"휴지통을 거치지 않고 영구 삭제": "Delete permanently without the trash", "휴지통을 거치지 않고 영구 삭제": "Delete permanently without the trash",
"휴지통을 거치지 않고 영구 삭제 (Shift+Delete)": "Delete permanently without the Recycle Bin (Shift+Delete)", "휴지통을 거치지 않고 영구 삭제 (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 (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 off - deleting removes items permanently right away (Settings > File explorer > Keep in trash).",
"휴지통이 비어 있습니다": "The trash is empty", "휴지통이 비어 있습니다": "The trash is empty",

View File

@ -2073,6 +2073,7 @@
"원본에 없는 것을 대상에서 지우기": "元にないものを対象から削除", "원본에 없는 것을 대상에서 지우기": "元にないものを対象から削除",
"원본에는 아무것도 없는데 대상에는 {0}개가 있습니다. SD 카드가 빠졌거나 네트워크 · 권한 문제일 수 있습니다. 일부러 원본을 비웠다면 지울 목록을 보고 [그래도 맞추기] 를 누르세요.": "元には何もありませんが、対象には {0} 個あります。SD カードが抜けたか、ネットワーク・権限の問題かもしれません。意図的に元を空にした場合は、削除する一覧を見て [それでも同期] を押してください。", "원본에는 아무것도 없는데 대상에는 {0}개가 있습니다. SD 카드가 빠졌거나 네트워크 · 권한 문제일 수 있습니다. 일부러 원본을 비웠다면 지울 목록을 보고 [그래도 맞추기] 를 누르세요.": "元には何もありませんが、対象には {0} 個あります。SD カードが抜けたか、ネットワーク・権限の問題かもしれません。意図的に元を空にした場合は、削除する一覧を見て [それでも同期] を押してください。",
"원본으로": "元の画像で", "원본으로": "元の画像で",
"원본은 휴지통으로 갑니다 - 휴지통에 평문 (암호화하지 않은 원본) 이 남습니다": "元のファイルはごみ箱に移ります - ごみ箱に暗号化されていない元のファイルが残ります",
"원본을 읽을 수 없어 멈춤": "元を読めないため停止", "원본을 읽을 수 없어 멈춤": "元を読めないため停止",
"원본을 읽을 수 없어 멈췄습니다": "元を読めないため停止しました", "원본을 읽을 수 없어 멈췄습니다": "元を読めないため停止しました",
"원본을 지우는 실행이라, 옮길 목록이 나온 뒤에 실행할 수 있습니다.": "元のファイルを削除する実行のため、移動する一覧が表示されてから実行できます。", "원본을 지우는 실행이라, 옮길 목록이 나온 뒤에 실행할 수 있습니다.": "元のファイルを削除する実行のため、移動する一覧が表示されてから実行できます。",
@ -2599,6 +2600,7 @@
"지난 업데이트가 남긴 임시 파일을 지움: {0}": "前回のアップデートが残した一時ファイルを削除: {0}", "지난 업데이트가 남긴 임시 파일을 지움: {0}": "前回のアップデートが残した一時ファイルを削除: {0}",
"지난 작업 기록": "過去の作業記録", "지난 작업 기록": "過去の作業記録",
"지난번에 남은 문서 보기 임시 폴더를 지움: {0}개": "前回残った文書表示の一時フォルダーを削除: {0} 個", "지난번에 남은 문서 보기 임시 폴더를 지움: {0}개": "前回残った文書表示の一時フォルダーを削除: {0} 個",
"지난번에 남은 보관함 보기 임시 폴더를 지움: {0}개": "前回残った保管庫表示の一時フォルダーを削除: {0} 個",
"지문 확인하고 연결": "フィンガープリントを確認して接続", "지문 확인하고 연결": "フィンガープリントを確認して接続",
"지우고 맞추기": "削除して同期", "지우고 맞추기": "削除して同期",
"지우고 초기화": "削除して初期化", "지우고 초기화": "削除して初期化",
@ -3033,10 +3035,12 @@
"휴지통에서 지운 것은 되살릴 수 없습니다.": "ゴミ箱から削除したものは元に戻せません。", "휴지통에서 지운 것은 되살릴 수 없습니다.": "ゴミ箱から削除したものは元に戻せません。",
"휴지통에서 찾지 못했습니다 (이미 비웠거나 되돌림)": "ごみ箱に見つかりません (空にしたか元に戻した)", "휴지통에서 찾지 못했습니다 (이미 비웠거나 되돌림)": "ごみ箱に見つかりません (空にしたか元に戻した)",
"휴지통으로": "ごみ箱へ", "휴지통으로": "ごみ箱へ",
"휴지통으로 (처음 값)": "ごみ箱へ (既定)",
"휴지통으로 보내기": "ごみ箱に移動", "휴지통으로 보내기": "ごみ箱に移動",
"휴지통으로 옮기지 못했습니다: {0} ({1})": "ゴミ箱に移せませんでした: {0} ({1})", "휴지통으로 옮기지 못했습니다: {0} ({1})": "ゴミ箱に移せませんでした: {0} ({1})",
"휴지통을 거치지 않고 영구 삭제": "ゴミ箱を使わずに完全に削除", "휴지통을 거치지 않고 영구 삭제": "ゴミ箱を使わずに完全に削除",
"휴지통을 거치지 않고 영구 삭제 (Shift+Delete)": "ごみ箱を使わずに完全に削除 (Shift+Delete)", "휴지통을 거치지 않고 영구 삭제 (Shift+Delete)": "ごみ箱を使わずに完全に削除 (Shift+Delete)",
"휴지통을 거치지 않습니다 - 지우기 전에 확인 창을 한 번 더 띄웁니다": "ごみ箱を経由しません - 削除前にもう一度確認します",
"휴지통이 꺼져 있습니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "ゴミ箱はオフです (設定 > ファイルエクスプローラー > ゴミ箱の保管)。", "휴지통이 꺼져 있습니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "ゴミ箱はオフです (設定 > ファイルエクスプローラー > ゴミ箱の保管)。",
"휴지통이 꺼져 있습니다 - 지우면 바로 영구 삭제됩니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "ゴミ箱はオフです - 削除するとすぐに完全削除されます (設定 > ファイルエクスプローラー > ゴミ箱の保管)。", "휴지통이 꺼져 있습니다 - 지우면 바로 영구 삭제됩니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "ゴミ箱はオフです - 削除するとすぐに完全削除されます (設定 > ファイルエクスプローラー > ゴミ箱の保管)。",
"휴지통이 비어 있습니다": "ゴミ箱は空です", "휴지통이 비어 있습니다": "ゴミ箱は空です",

View File

@ -2073,6 +2073,7 @@
"원본에 없는 것을 대상에서 지우기": "从目标中删除源中没有的内容", "원본에 없는 것을 대상에서 지우기": "从目标中删除源中没有的内容",
"원본에는 아무것도 없는데 대상에는 {0}개가 있습니다. SD 카드가 빠졌거나 네트워크 · 권한 문제일 수 있습니다. 일부러 원본을 비웠다면 지울 목록을 보고 [그래도 맞추기] 를 누르세요.": "源中没有任何内容,但目标中有 {0} 个项目。可能是 SD 卡被拔出,或网络 · 权限有问题。如果你有意清空了源,请查看要删除的列表并按 [仍然同步]。", "원본에는 아무것도 없는데 대상에는 {0}개가 있습니다. SD 카드가 빠졌거나 네트워크 · 권한 문제일 수 있습니다. 일부러 원본을 비웠다면 지울 목록을 보고 [그래도 맞추기] 를 누르세요.": "源中没有任何内容,但目标中有 {0} 个项目。可能是 SD 卡被拔出,或网络 · 权限有问题。如果你有意清空了源,请查看要删除的列表并按 [仍然同步]。",
"원본으로": "回到原图", "원본으로": "回到原图",
"원본은 휴지통으로 갑니다 - 휴지통에 평문 (암호화하지 않은 원본) 이 남습니다": "原文件将进入回收站 - 回收站中会留下未加密的原文件",
"원본을 읽을 수 없어 멈춤": "无法读取源,已停止", "원본을 읽을 수 없어 멈춤": "无法读取源,已停止",
"원본을 읽을 수 없어 멈췄습니다": "无法读取源,已停止", "원본을 읽을 수 없어 멈췄습니다": "无法读取源,已停止",
"원본을 지우는 실행이라, 옮길 목록이 나온 뒤에 실행할 수 있습니다.": "此操作会删除源文件,需在显示要移动的列表后才能执行。", "원본을 지우는 실행이라, 옮길 목록이 나온 뒤에 실행할 수 있습니다.": "此操作会删除源文件,需在显示要移动的列表后才能执行。",
@ -2599,6 +2600,7 @@
"지난 업데이트가 남긴 임시 파일을 지움: {0}": "已删除上次更新留下的临时文件: {0}", "지난 업데이트가 남긴 임시 파일을 지움: {0}": "已删除上次更新留下的临时文件: {0}",
"지난 작업 기록": "旧任务记录", "지난 작업 기록": "旧任务记录",
"지난번에 남은 문서 보기 임시 폴더를 지움: {0}개": "已删除上次留下的文档查看临时文件夹: {0} 个", "지난번에 남은 문서 보기 임시 폴더를 지움: {0}개": "已删除上次留下的文档查看临时文件夹: {0} 个",
"지난번에 남은 보관함 보기 임시 폴더를 지움: {0}개": "已删除上次残留的保管库查看临时文件夹: {0} 个",
"지문 확인하고 연결": "确认指纹并连接", "지문 확인하고 연결": "确认指纹并连接",
"지우고 맞추기": "删除并同步", "지우고 맞추기": "删除并同步",
"지우고 초기화": "删除并重置", "지우고 초기화": "删除并重置",
@ -3033,10 +3035,12 @@
"휴지통에서 지운 것은 되살릴 수 없습니다.": "从回收站删除的项目无法还原。", "휴지통에서 지운 것은 되살릴 수 없습니다.": "从回收站删除的项目无法还原。",
"휴지통에서 찾지 못했습니다 (이미 비웠거나 되돌림)": "在回收站中找不到 (已清空或已恢复)", "휴지통에서 찾지 못했습니다 (이미 비웠거나 되돌림)": "在回收站中找不到 (已清空或已恢复)",
"휴지통으로": "移到回收站", "휴지통으로": "移到回收站",
"휴지통으로 (처음 값)": "移到回收站 (默认)",
"휴지통으로 보내기": "移到回收站", "휴지통으로 보내기": "移到回收站",
"휴지통으로 옮기지 못했습니다: {0} ({1})": "无法移到回收站: {0} ({1})", "휴지통으로 옮기지 못했습니다: {0} ({1})": "无法移到回收站: {0} ({1})",
"휴지통을 거치지 않고 영구 삭제": "不经回收站永久删除", "휴지통을 거치지 않고 영구 삭제": "不经回收站永久删除",
"휴지통을 거치지 않고 영구 삭제 (Shift+Delete)": "不经过回收站永久删除 (Shift+Delete)", "휴지통을 거치지 않고 영구 삭제 (Shift+Delete)": "不经过回收站永久删除 (Shift+Delete)",
"휴지통을 거치지 않습니다 - 지우기 전에 확인 창을 한 번 더 띄웁니다": "不经过回收站 - 删除前会再次确认",
"휴지통이 꺼져 있습니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "回收站已关闭 (设置 > 文件资源管理器 > 回收站保留)。", "휴지통이 꺼져 있습니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "回收站已关闭 (设置 > 文件资源管理器 > 回收站保留)。",
"휴지통이 꺼져 있습니다 - 지우면 바로 영구 삭제됩니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "回收站已关闭 - 删除会立即永久删除 (设置 > 文件资源管理器 > 回收站保留)。", "휴지통이 꺼져 있습니다 - 지우면 바로 영구 삭제됩니다 (환경 설정 > 파일 탐색기 > 휴지통 보관).": "回收站已关闭 - 删除会立即永久删除 (设置 > 文件资源管理器 > 回收站保留)。",
"휴지통이 비어 있습니다": "回收站为空", "휴지통이 비어 있습니다": "回收站为空",

View File

@ -33,6 +33,7 @@ import '../services/system_usage.dart';
import 'settings.dart'; import 'settings.dart';
import 'cleanup.dart' show removeUpdateLeftovers; import 'cleanup.dart' show removeUpdateLeftovers;
import '../core/hwp_source.dart' show removeStaleHwpWork; import '../core/hwp_source.dart' show removeStaleHwpWork;
import '../core/vault_view.dart' show removeStaleVaultViews, vaultViewRootOf;
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import '../core/remote_idle.dart'; import '../core/remote_idle.dart';
@ -221,6 +222,11 @@ class AppController extends ChangeNotifier {
final hwp = removeStaleHwpWork(await services.storage.tempDirectory()); final hwp = removeStaleHwpWork(await services.storage.tempDirectory());
if (hwp.isNotEmpty) _log(trf('지난번에 남은 문서 보기 임시 폴더를 지움: {0}개', [hwp.length])); if (hwp.isNotEmpty) _log(trf('지난번에 남은 문서 보기 임시 폴더를 지움: {0}개', [hwp.length]));
} catch (_) {} } catch (_) {}
// 관리자 지시 (10/21): 강제로 꺼져 남은 암호 보관함 보기 폴더 (잠깐 푼 평문)
try {
final vault = removeStaleVaultViews((await vaultViewRootOf()).path);
if (vault.isNotEmpty) _log(trf('지난번에 남은 보관함 보기 임시 폴더를 지움: {0}개', [vault.length]));
} catch (_) {}
}()); }());
ffmpegVersion = await _tool.version(); ffmpegVersion = await _tool.version();
if (ffmpegVersion == null) { if (ffmpegVersion == null) {

View File

@ -224,6 +224,13 @@ class Vault {
await tmp.rename(outPath); await tmp.rename(outPath);
} }
/// 메모리로 푼다 (관리자 지시 10/21: 그림 보기는 파일로 풀지 않음). [maxBytes] 보다 크면 [StateError]
Future<Uint8List> 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<void> remove(VaultEntry e) async { Future<void> remove(VaultEntry e) async {
_entries.removeWhere((x) => x.blob == e.blob); _entries.removeWhere((x) => x.blob == e.blob);

30
lib/core/vault_view.dart Normal file
View File

@ -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<Directory> Function() vaultViewRootOf = () async {
if (Platform.isAndroid) return getTemporaryDirectory();
return Directory(p.join((await getApplicationSupportDirectory()).path, 'temp'));
};
/// 푼 파일을 두는 폴더 이름 앞 (실행마다 새 폴더)
const vaultViewPrefix = 'jj_vault_view_';
/// 앱이 강제로 꺼져 남은 보기 폴더를 지운다 (앱을 켤 때 - 그때는 보관함이 열려 있지 않음). 지운 이름
List<String> removeStaleVaultViews(String root) {
final removed = <String>[];
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;
}

View File

@ -2196,7 +2196,11 @@ class _ExplorerPageState extends State<ExplorerPage> {
case 'sqlite': case 'sqlite':
await openSqliteViewer(context, c, e.path); await openSqliteViewer(context, c, e.path);
case 'vaultAdd': 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)]); await _refreshAll([vDirname(e.path)]);
case 'openWith': case 'openWith':
final local = await _fetch(e.path); final local = await _fetch(e.path);

View File

@ -16,6 +16,7 @@ import '../core/reader_sources.dart';
import '../core/secret_gate.dart'; import '../core/secret_gate.dart';
import '../core/vault_crypto.dart'; import '../core/vault_crypto.dart';
import '../core/vault_store.dart'; import '../core/vault_store.dart';
import '../core/vault_view.dart';
import '../core/vfs.dart' show vDisplay; import '../core/vfs.dart' show vDisplay;
import '../l10n/tr.dart'; import '../l10n/tr.dart';
import 'player_page.dart'; import 'player_page.dart';
@ -24,7 +25,7 @@ import 'theme.dart';
/// X-plore 비교 5-2 암호 보관함 화면 (사용자 결정 10/21, 관리자 결정 - 설계안 위키 프로젝트/암호 보관함 · 루트 설계안 (10-21)) /// X-plore 비교 5-2 암호 보관함 화면 (사용자 결정 10/21, 관리자 결정 - 설계안 위키 프로젝트/암호 보관함 · 루트 설계안 (10-21))
Future<void> openVault(BuildContext context, AppController c, Future<void> openVault(BuildContext context, AppController c,
{List<String> add = const [], Future<void> Function(List<String> paths)? deleteOriginals}) => {List<String> add = const [], Future<void> Function(List<String> paths, {required bool permanent})? deleteOriginals}) =>
Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => VaultPage(c: c, add: add, deleteOriginals: deleteOriginals))); Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => VaultPage(c: c, add: add, deleteOriginals: deleteOriginals)));
/// 보관함 폴더 (Android: Download/JJ_MKVMaker/vault · Windows: 문서\JJ_MKVMaker\vault). 시험에서 바꿈 /// 보관함 폴더 (Android: Download/JJ_MKVMaker/vault · Windows: 문서\JJ_MKVMaker\vault). 시험에서 바꿈
@ -48,8 +49,8 @@ class VaultPage extends StatefulWidget {
/// 열면서 넣을 파일 (탐색기 [보관함에 넣기]) /// 열면서 넣을 파일 (탐색기 [보관함에 넣기])
final List<String> add; final List<String> add;
/// [원본 지우기 (이동)] 일 때 탐색기의 지우기 (확인 · 휴지통) /// [원본 지우기 (이동)] 일 때 탐색기의 지우기 (확인 창 · [permanent] 가 아니면 휴지통)
final Future<void> Function(List<String> paths)? deleteOriginals; final Future<void> Function(List<String> paths, {required bool permanent})? deleteOriginals;
const VaultPage({super.key, required this.c, this.add = const [], this.deleteOriginals}); const VaultPage({super.key, required this.c, this.add = const [], this.deleteOriginals});
@override @override
@ -83,7 +84,7 @@ class _VaultPageState extends State<VaultPage> with WidgetsBindingObserver {
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
_lock(); _lock(closeViews: false);
_pw.dispose(); _pw.dispose();
_pw2.dispose(); _pw2.dispose();
super.dispose(); super.dispose();
@ -103,19 +104,43 @@ class _VaultPageState extends State<VaultPage> with WidgetsBindingObserver {
} }
} }
void _lock() { /// 그림은 메모리에서 바로 (파일로 풀지 않음)
static const _imageMax = 256 * 1024 * 1024;
/// 이 화면의 길 (잠글 때 위에 열린 보기 화면을 닫으려고)
ModalRoute<dynamic>? _route;
/// 잠금: 열쇠를 버리고, 위에 열린 보기 (그림 · 플레이어) 를 닫고, 잠깐 푼 파일을 지운다 (관리자 지시 10/21)
void _lock({bool closeViews = true}) {
_vault = null; _vault = null;
if (_stage == _Stage.open) _stage = _Stage.locked; 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()); unawaited(_clearViewDir());
} }
Future<Directory> _viewDir() async => Directory(p.join(await c.services.storage.tempDirectory(), 'jj_vault_view')); /// 이번에 연 동안 잠깐 푼 파일을 두는 앱 전용 폴더 (jj_vault_view_…)
Directory? _session;
Future<Directory> _viewDir() async {
final d = _session ??= Directory(p.join((await vaultViewRootOf()).path, '$vaultViewPrefix${DateTime.now().microsecondsSinceEpoch}'));
await d.create(recursive: true);
return d;
}
Future<void> _clearViewDir() async { Future<void> _clearViewDir() async {
try { final d = _session;
final d = await _viewDir(); _session = null;
if (await d.exists()) await d.delete(recursive: true); if (d == null) return;
} catch (_) {} // Windows 는 막 쓴 파일을 백신 · 색인이 잠깐 잡고 있어 지우기가 실패할 수 있다 - 몇 번 더 (남으면 다음에 켤 때 지움)
for (var i = 0; i < 10; i++) {
try {
if (await d.exists()) await d.delete(recursive: true);
return;
} catch (_) {
await Future<void>.delayed(const Duration(milliseconds: 200));
}
}
} }
Future<void> _check() async { Future<void> _check() async {
@ -244,6 +269,8 @@ class _VaultPageState extends State<VaultPage> with WidgetsBindingObserver {
if (paths.isEmpty || _vault == null) return; if (paths.isEmpty || _vault == null) return;
_pendingAdd = []; _pendingAdd = [];
var move = false; var move = false;
// 관리자 지시 (10/21): 휴지통으로 보내면 평문 원본이 휴지통에 남는다 - 알리고 고르게 (처음 값 휴지통: 실수로 잃지 않게)
var permanent = false;
final go = await showDialog<bool>( final go = await showDialog<bool>(
context: context, context: context,
builder: (ctx) => StatefulBuilder( builder: (ctx) => StatefulBuilder(
@ -262,6 +289,23 @@ class _VaultPageState extends State<VaultPage> with WidgetsBindingObserver {
title: Text(tr('원본 지우기 (이동)')), title: Text(tr('원본 지우기 (이동)')),
subtitle: Text(tr('다 넣고 확인한 뒤에 탐색기의 지우기 (확인 · 휴지통) 로 지웁니다. 끄면 원본은 그대로 (복사)')), subtitle: Text(tr('다 넣고 확인한 뒤에 탐색기의 지우기 (확인 · 휴지통) 로 지웁니다. 끄면 원본은 그대로 (복사)')),
), ),
if (move) ...[
Text(tr('원본은 휴지통으로 갑니다 - 휴지통에 평문 (암호화하지 않은 원본) 이 남습니다'),
style: const TextStyle(fontSize: 12, color: Colors.orangeAccent)),
RadioGroup<bool>(
groupValue: permanent,
onChanged: (v) => set(() => permanent = v ?? false),
child: Column(mainAxisSize: MainAxisSize.min, children: [
RadioListTile<bool>(dense: true, contentPadding: EdgeInsets.zero, value: false, title: Text(tr('휴지통으로 (처음 값)'))),
RadioListTile<bool>(
dense: true,
contentPadding: EdgeInsets.zero,
value: true,
title: Text(tr('영구 삭제')),
subtitle: Text(tr('휴지통을 거치지 않습니다 - 지우기 전에 확인 창을 한 번 더 띄웁니다'))),
]),
),
],
]), ]),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
@ -284,28 +328,42 @@ class _VaultPageState extends State<VaultPage> with WidgetsBindingObserver {
if (mounted) setState(() => _busy = false); if (mounted) setState(() => _busy = false);
} }
if (done.isNotEmpty) _snack(trf('{0}개를 보관함에 넣었습니다', [done.length])); 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<void> _view(VaultEntry e) async { Future<void> _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 { try {
// 동영상 · 음악: 앱 전용 폴더 (jj_vault_view_…) 에 잠깐 풀고, 닫으면 · 잠그면 · 다음에 켤 때 지운다
final d = await _viewDir(); final d = await _viewDir();
await d.create(recursive: true);
final out = p.join(d.path, 'v_${DateTime.now().microsecondsSinceEpoch}${p.extension(e.name)}'); final out = p.join(d.path, 'v_${DateTime.now().microsecondsSinceEpoch}${p.extension(e.name)}');
setState(() => _busy = true); setState(() => _busy = true);
await _vault!.export(e, out); await _vault!.export(e, out);
if (mounted) setState(() => _busy = false); if (mounted) setState(() => _busy = false);
if (!mounted) return; 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; final create = c.services.createMediaPlayer;
if (create != null) { if (create != null) {
await Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => PlayerPage(c: c, player: create(), files: [out]))); await Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => PlayerPage(c: c, player: create(), files: [out])));
} }
} else {
_snack(tr('이 종류는 앱 안에서 볼 수 없습니다 - [꺼내기] 로 꺼내서 보세요'));
} }
// 본 뒤 풀어 둔 것은 지운다 // 본 뒤 풀어 둔 것은 지운다
await File(out).delete().catchError((_) => File(out)); await File(out).delete().catchError((_) => File(out));
@ -432,7 +490,12 @@ class _VaultPageState extends State<VaultPage> with WidgetsBindingObserver {
} }
@override @override
Widget build(BuildContext context) => Scaffold( Widget build(BuildContext context) {
_route ??= ModalRoute.of(context);
return _scaffold();
}
Widget _scaffold() => Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(tr('암호 보관함')), title: Text(tr('암호 보관함')),
actions: [ actions: [
@ -585,3 +648,19 @@ class _VaultPageState extends State<VaultPage> 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<ReaderImage> load(int i, {required int maxWidth}) async =>
ReaderImage(provider: ResizeImage.resizeIfNeeded(maxWidth, null, MemoryImage(bytes)));
}

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; 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'; import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
/// 모든 단위 시험 파일 앞에 돈다 (flutter_test 의 flutter_test_config). /// 모든 단위 시험 파일 앞에 돈다 (flutter_test 의 flutter_test_config).
@ -10,6 +11,8 @@ import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
Future<void> testExecutable(FutureOr<void> Function() testMain) async { Future<void> testExecutable(FutureOr<void> Function() testMain) async {
final root = Directory.systemTemp.createTempSync('jj_store_'); final root = Directory.systemTemp.createTempSync('jj_store_');
DesktopStorageService.tempRoot = root.path; DesktopStorageService.tempRoot = root.path;
// 암호 보관함 보기 임시 폴더도 (기본은 실제 앱 데이터 폴더 - 시험이 건드리지 않게)
vaultViewRootOf = () async => Directory('${root.path}${Platform.pathSeparator}vault_view');
tearDownAll(() { tearDownAll(() {
try { try {
root.deleteSync(recursive: true); root.deleteSync(recursive: true);

View File

@ -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/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart'; import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/services/secret_store.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/explorer_page.dart';
import 'package:jj_mkvmaker/ui/reader_page.dart' show ReaderPage;
import 'package:jj_mkvmaker/ui/vault_page.dart'; import 'package:jj_mkvmaker/ui/vault_page.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
@ -41,7 +44,7 @@ void main() {
Future<void> until(WidgetTester t, bool Function() ok, {int rounds = 200}) async { Future<void> until(WidgetTester t, bool Function() ok, {int rounds = 200}) async {
for (var i = 0; i < rounds && !ok(); i++) { for (var i = 0; i < rounds && !ok(); i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 20))); await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 20)));
await t.pump(); await t.pump(const Duration(milliseconds: 20));
} }
// 닫히는 창 · 메뉴의 막 (barrier) 이 사라질 때까지 // 닫히는 창 · 메뉴의 막 (barrier) 이 사라질 때까지
for (var i = 0; i < 8; i++) { for (var i = 0; i < 8; i++) {
@ -71,9 +74,13 @@ void main() {
addTearDown(t.view.reset); addTearDown(t.view.reset);
final src = File(p.join(tmp.path, '비밀 문서.pdf'))..writeAsBytesSync(List.generate(3000, (i) => i % 256)); final src = File(p.join(tmp.path, '비밀 문서.pdf'))..writeAsBytesSync(List.generate(3000, (i) => i % 256));
final deleted = <List<String>>[]; final deleted = <List<String>>[];
final permanents = <bool>[];
final c = ctl(); final c = ctl();
await t.pumpWidget(MaterialApp( 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); await until(t, () => find.text('새 보관함 만들기').evaluate().isNotEmpty);
expect(find.textContaining('다른 앱도 이 폴더의 암호화된 파일을 볼 수 있습니다'), findsOneWidget); expect(find.textContaining('다른 앱도 이 폴더의 암호화된 파일을 볼 수 있습니다'), findsOneWidget);
expect(find.textContaining('이 폴더를 지우면 보관함도 사라집니다'), findsOneWidget); expect(find.textContaining('이 폴더를 지우면 보관함도 사라집니다'), findsOneWidget);
@ -91,9 +98,15 @@ void main() {
expect(t.widget<CheckboxListTile>(find.byType(CheckboxListTile)).value, isFalse, reason: '처음은 복사'); expect(t.widget<CheckboxListTile>(find.byType(CheckboxListTile)).value, isFalse, reason: '처음은 복사');
await t.tap(find.text('원본 지우기 (이동)')); await t.tap(find.text('원본 지우기 (이동)'));
await t.pump(); 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 t.tap(find.widgetWithText(FilledButton, '넣기'));
await until(t, () => deleted.isNotEmpty); await until(t, () => deleted.isNotEmpty);
expect(deleted.single, [src.path], reason: '다 넣은 뒤 탐색기의 지우기로'); expect(deleted.single, [src.path], reason: '다 넣은 뒤 탐색기의 지우기로');
expect(permanents.single, isTrue, reason: '영구 삭제를 골랐음 (탐색기가 영구 삭제 확인 창을 한 번 더)');
expect(find.text('비밀 문서.pdf'), findsOneWidget); expect(find.text('비밀 문서.pdf'), findsOneWidget);
expect(Directory(p.join(tmp.path, 'vault')).listSync().map((f) => p.basename(f.path)).join(), isNot(contains('비밀'))); 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 t.tap(find.widgetWithText(FilledButton, '지우기'));
await until(t, () => find.text('보관함이 비어 있습니다').evaluate().isNotEmpty); await until(t, () => find.text('보관함이 비어 있습니다').evaluate().isNotEmpty);
expect(find.text('보관함이 비어 있습니다'), findsOneWidget); 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()); await t.pumpWidget(const SizedBox());
}); });
@ -175,6 +190,8 @@ void main() {
await t.tap(find.widgetWithText(FilledButton, '열기')); await t.tap(find.widgetWithText(FilledButton, '열기'));
await until(t, () => find.text('보관함이 비어 있습니다').evaluate().isNotEmpty); await until(t, () => find.text('보관함이 비어 있습니다').evaluate().isNotEmpty);
expect(find.text('보관함이 비어 있습니다'), findsOneWidget); 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()); await t.pumpWidget(const SizedBox());
}); });
testWidgets('파일 탐색기: [보관함에 넣기] 는 보관함을 켰을 때 · 이 기기의 파일에만 (폴더는 2단계)', (t) async { testWidgets('파일 탐색기: [보관함에 넣기] 는 보관함을 켰을 때 · 이 기기의 파일에만 (폴더는 2단계)', (t) async {
@ -207,4 +224,60 @@ void main() {
await t.pump(const Duration(seconds: 1)); 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<String> leftovers() =>
viewRoot.existsSync() ? viewRoot.listSync(recursive: true).map((e) => e.path).toList() : const <String>[];
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());
});
} }