JJ_MKVMaker/lib/ui/vault_page.dart

1096 lines
45 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../app/app_controller.dart';
import '../app/master_lock.dart';
import '../core/file_ops.dart' show formatSize;
import '../core/playlist.dart' show isAudioFile, isVideoFile;
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/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;
import 'tail_text.dart';
import 'theme.dart';
/// X-plore 비교 5-2 암호 보관함 화면 (사용자 결정 10/21, 관리자 결정 - 설계안 위키 프로젝트/암호 보관함 · 루트 설계안 (10-21))
Future<void> openVault(BuildContext context, AppController c,
{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)));
/// 보관함 폴더 (Android: Download/JJ_MKVMaker/vault · Windows: 문서\JJ_MKVMaker\vault). 시험에서 바꿈
Future<Directory> Function() vaultDirOf = () async {
if (Platform.isAndroid) {
final root = await const MethodChannel('jj_mkvmaker/android').invokeMethod<String>('storageRoot') ?? '/storage/emulated/0';
return Directory(p.join(root, 'Download', 'JJ_MKVMaker', 'vault'));
}
return Directory(p.join((await getApplicationDocumentsDirectory()).path, 'JJ_MKVMaker', 'vault'));
};
/// 꺼낼 폴더 고르기 (시험에서 바꿈)
Future<String?> Function() vaultPickFolder = () => FilePicker.getDirectoryPath();
/// 시험: PBKDF2 반복 수 (새 보관함 · 비밀번호 바꾸기)
int vaultIterationsForTest = VaultCrypto.defaultIterations;
class VaultPage extends StatefulWidget {
final AppController c;
/// 열면서 넣을 파일 (탐색기 [보관함에 넣기])
final List<String> add;
/// [원본 지우기 (이동)] 일 때 탐색기의 지우기 (확인 창 · [permanent] 가 아니면 휴지통)
final Future<void> Function(List<String> paths, {required bool permanent})? deleteOriginals;
const VaultPage({super.key, required this.c, this.add = const [], this.deleteOriginals});
@override
State<VaultPage> createState() => _VaultPageState();
}
enum _Stage { checking, create, locked, opening, open }
class _VaultPageState extends State<VaultPage> with WidgetsBindingObserver {
_Stage _stage = _Stage.checking;
Directory? _dir;
Vault? _vault;
String? _error;
int _fails = 0;
DateTime? _waitUntil, _hiddenAt;
final _pw = TextEditingController(), _pw2 = TextEditingController();
bool _understood = false;
bool _busy = false;
late List<String> _pendingAdd = [...widget.add];
String _sort = 'added';
AppController get c => widget.c;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
unawaited(_check());
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_lock(closeViews: false);
_pw.dispose();
_pw2.dispose();
super.dispose();
}
/// 앱이 뒤로 간 뒤 정한 시간이 지나 돌아오면 다시 잠금
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused || state == AppLifecycleState.hidden) {
_hiddenAt ??= DateTime.now();
} else if (state == AppLifecycleState.resumed) {
final at = _hiddenAt;
_hiddenAt = null;
if (at != null && _vault != null && DateTime.now().difference(at).inSeconds >= c.settings.vaultAutoLockSeconds) {
setState(_lock);
}
}
}
/// 그림은 메모리에서 바로 (파일로 풀지 않음)
static const _imageMax = 256 * 1024 * 1024;
/// 이 화면의 길 (잠글 때 위에 열린 보기 화면을 닫으려고)
ModalRoute<dynamic>? _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());
}
/// 이번에 연 동안 잠깐 푼 파일을 두는 앱 전용 폴더 (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 {
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<void>.delayed(const Duration(milliseconds: 200));
}
}
}
Future<void> _check() async {
try {
final d = await vaultDirOf();
final has = await Vault.exists(d);
if (mounted) {
setState(() {
_dir = d;
_stage = has ? _Stage.locked : _Stage.create;
});
}
} catch (e) {
if (mounted) setState(() => _error = trf('보관함 폴더를 열 수 없습니다: {0}', [e]));
}
}
void _snack(String t) => ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(content: Text(t)));
Future<void> _create() async {
if (_pw.text.isEmpty || _pw.text != _pw2.text || !_understood) return;
setState(() {
_busy = true;
_stage = _Stage.opening;
});
try {
final v = await Vault.create(_dir!, _pw.text, iterations: vaultIterationsForTest);
_pw.clear();
_pw2.clear();
if (!mounted) return;
setState(() {
_vault = v;
_stage = _Stage.open;
});
await _addPending();
} catch (e) {
if (mounted) {
setState(() {
_stage = _Stage.create;
_error = trf('보관함을 만들지 못했습니다: {0}', [e]);
});
}
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _unlock() async {
final wait = _waitUntil;
if (wait != null && DateTime.now().isBefore(wait)) return;
setState(() {
_stage = _Stage.opening;
_error = null;
});
try {
final v = await Vault.unlock(_dir!, _pw.text);
_pw.clear();
_fails = 0;
if (!mounted) return;
setState(() {
_vault = v;
_stage = _Stage.open;
});
await _addPending();
} on VaultAuthException {
_fails++;
if (_fails >= 5) {
_waitUntil = DateTime.now().add(const Duration(seconds: 30));
_fails = 0;
Timer(const Duration(seconds: 30), () => mounted ? setState(() => _waitUntil = null) : null);
}
if (mounted) {
setState(() {
_stage = _Stage.locked;
_error = _waitUntil != null ? tr('비밀번호가 여러 번 틀려 30초 뒤에 다시 넣을 수 있습니다') : tr('비밀번호가 틀렸습니다');
});
}
} on VaultFormatException {
if (mounted) {
setState(() {
_stage = _Stage.locked;
_error = tr('이 판이 모르는 보관함 형식입니다 - 앱을 새 판으로 올려 주세요 (보관함은 그대로 둡니다)');
});
}
} catch (e) {
if (mounted) {
setState(() {
_stage = _Stage.locked;
_error = trf('보관함을 열지 못했습니다: {0}', [e]);
});
}
}
}
/// [마스터로도 열기]: 마스터 비밀번호 확인 뒤 안전 저장소의 열쇠로
Future<void> _unlockWithMaster() async {
final lock = MasterLock.instance;
if (lock == null || !await SecretGate.pass(force: true)) return;
final key = (await lock.secrets.readAll())[MasterLock.vaultCopyKey];
if (key == null) {
_snack(tr('마스터로 여는 열쇠가 없습니다 (마스터를 초기화했으면 없어집니다) - 보관함 비밀번호로 여세요'));
return;
}
setState(() => _stage = _Stage.opening);
try {
final v = await Vault.unlockWithKey(_dir!, base64.decode(key));
if (!mounted) return;
setState(() {
_vault = v;
_stage = _Stage.open;
});
await _addPending();
} catch (e) {
if (mounted) {
setState(() {
_stage = _Stage.locked;
_error = trf('보관함을 열지 못했습니다: {0}', [e]);
});
}
}
}
/// 넣을 것을 펼친다 (2단계, 관리자 지시 10/21): 폴더는 안의 구조 그대로 (이름은 목록에만), 원격 (WebDAV · SFTP · SMB · FTP · 시스템 폴더) 도.
/// 맨 위 폴더 이름이 지금 폴더에 이미 있으면 합치지 않고 " (2)" (내 판단: 모르는 새 섞이지 않게)
Future<_AddPlan> _plan(List<String> targets) async {
final plan = _AddPlan();
final v = _vault!;
final taken = <String>{};
for (final t in targets) {
try {
if (!await vIsDir(t)) {
plan.files.add((src: t, folder: _cwd, top: t));
continue;
}
var name = v.freeName(_cwd, vBasename(t));
for (var i = 2; taken.contains(name); i++) {
name = v.freeName(_cwd, '${vBasename(t)} ($i)');
}
taken.add(name);
final root = _cwd.isEmpty ? name : '$_cwd/$name';
if (Vault.cleanFolder(root) == null) throw FileSystemException(tr('쓸 수 없는 이름입니다'), t);
plan.folders.add(root);
Future<void> walk(String dir, String folder) async {
for (final x in await vList(dir, strict: true)) {
final n = vBasename(x.path);
if (!Vault.validName(n)) throw FileSystemException(tr('쓸 수 없는 이름입니다'), x.path);
if (x.isDir) {
plan.folders.add('$folder/$n');
await walk(x.path, '$folder/$n');
} else {
plan.files.add((src: x.path, folder: folder, top: t));
}
}
}
await walk(t, root);
} catch (e) {
plan.failed[t] = e;
}
}
// 읽지 못한 폴더는 반만 넣지 않는다
plan.files.removeWhere((x) => plan.failed.containsKey(x.top));
return plan;
}
/// 넣는 동안 위에 보이는 줄 ("3 / 10 · 사진.jpg")
String? _progress;
/// 탐색기에서 고른 것 넣기 - [원본 지우기 (이동)] 체크 (처음 끔)
Future<void> _addPending() async {
final paths = _pendingAdd;
if (paths.isEmpty || _vault == null) return;
_pendingAdd = [];
setState(() {
_busy = true;
_progress = tr('넣을 것을 세는 중…');
});
final plan = await _plan(paths);
if (!mounted) return;
setState(() {
_busy = false;
_progress = null;
});
final remote = paths.any(isDav);
var move = false;
// 관리자 지시 (10/21): 휴지통으로 보내면 평문 원본이 휴지통에 남는다 - 알리고 고르게 (처음 값 휴지통: 실수로 잃지 않게)
var permanent = false;
final go = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) => AlertDialog(
scrollable: true,
title: Text(trf('보관함에 넣기 · {0}개', [paths.length])),
content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
for (final x in paths.take(8)) Text('· ${vBasename(x)}', maxLines: 1, overflow: TextOverflow.ellipsis),
if (paths.length > 8) Text(trf('… 외 {0}개', [paths.length - 8])),
const SizedBox(height: 6),
Text(trf('파일 {0}개 · 폴더 {1}개 (폴더는 안의 구조 그대로)', [plan.files.length, plan.folders.length]),
style: const TextStyle(fontSize: 12.5)),
Text(trf('넣을 곳: {0}', [_cwdLabel()]), style: const TextStyle(fontSize: 12.5)),
if (remote)
Text(tr('원격 파일은 이 기기의 앱 전용 폴더에 받아 넣고, 받은 임시 파일은 바로 지웁니다'),
style: TextStyle(fontSize: 12, color: JjColors.textDim)),
for (final f in plan.failed.entries.take(3))
Text(trf('읽지 못해 빼고 넣습니다: {0} ({1})', [vBasename(f.key), f.value]),
style: TextStyle(fontSize: 12, color: JjColors.warning), maxLines: 2, overflow: TextOverflow.ellipsis),
const SizedBox(height: 8),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
value: move,
onChanged: widget.deleteOriginals == null ? null : (v) => set(() => move = v ?? false),
title: Text(tr('원본 지우기 (이동)')),
subtitle: Text(tr('다 넣고 확인한 뒤에 탐색기의 지우기 (확인 · 휴지통) 로 지웁니다. 끄면 원본은 그대로 (복사)')),
),
if (move) ...[
Text(tr('원본은 휴지통으로 갑니다 - 휴지통에 평문 (암호화하지 않은 원본) 이 남습니다'),
style: TextStyle(fontSize: 12, color: JjColors.warning)),
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: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(
onPressed: plan.files.isEmpty && plan.folders.isEmpty ? null : () => Navigator.pop(ctx, true), child: Text(tr('넣기'))),
],
),
),
);
if (go != true || !mounted) return;
setState(() => _busy = true);
final bad = <String>{...plan.failed.keys};
var done = 0;
Directory? tmp;
try {
for (final f in plan.folders) {
await _vault!.mkdir(f);
}
for (var i = 0; i < plan.files.length; i++) {
final x = plan.files[i];
if (_vault == null) break; // 넣는 동안 잠갔으면 멈춤
if (mounted) setState(() => _progress = '${i + 1} / ${plan.files.length} · ${vBasename(x.src)}');
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}'))
.create(recursive: true);
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);
if (bad.length == 1 + plan.failed.length) _snack(trf('넣지 못했습니다: {0}', [e]));
} finally {
if (fetched != null) await File(fetched).delete().catchError((_) => File(fetched!));
}
}
} catch (e) {
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(() {
_busy = false;
_progress = null;
});
}
}
if (done > 0 || bad.isEmpty) {
_snack(bad.isEmpty
? trf('{0}개를 보관함에 넣었습니다', [done])
: trf('{0}개를 넣었고 {1}개는 넣지 못했습니다 (그 원본은 지우지 않습니다)', [done, bad.length]));
}
// 다 넣은 것만 원본을 지운다 (폴더는 안의 것이 모두 들어갔을 때만)
final ok = [for (final x in paths) if (!bad.contains(x)) x];
if (move && ok.isNotEmpty && widget.deleteOriginals != null) await widget.deleteOriginals!(ok, permanent: permanent);
}
/// 앱 전용 임시 폴더 지우기 (백신 · 색인이 잠깐 잡고 있으면 몇 번 더)
static Future<void> _removeDir(Directory d) async {
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> _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);
if (err is VaultAuthException) return _broken(e, err);
_snack(trf('열 수 없습니다: {0}', [err]));
}
return;
}
if (!isVideoFile(e.name) && !isAudioFile(e.name)) {
_snack(tr('이 종류는 앱 안에서 볼 수 없습니다 - [꺼내기] 로 꺼내서 보세요'));
return;
}
try {
// 동영상 · 음악: 앱 전용 폴더 (jj_vault_view_…) 에 잠깐 풀고, 닫으면 · 잠그면 · 다음에 켤 때 지운다
final d = await _viewDir();
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 create = c.services.createMediaPlayer;
if (create != null) {
await Navigator.of(context).push(MaterialPageRoute<void>(builder: (_) => PlayerPage(c: c, player: create(), files: [out])));
}
}
// 본 뒤 풀어 둔 것은 지운다
await File(out).delete().catchError((_) => File(out));
} catch (err) {
if (mounted) setState(() => _busy = false);
if (err is VaultAuthException) return _broken(e, err);
_snack(trf('열 수 없습니다: {0}', [err]));
}
}
/// 내용 파일이 없거나 바뀐 항목 (관리자 지시 10/21, 28 지적): 이유 · [자세히] (원문) · [목록에서 빼기] (확인)
Future<void> _broken(VaultEntry e, Object err) async {
if (!mounted) return;
var detail = false;
final drop = await showDialog<bool>(
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<bool>(
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<void> _export(VaultEntry e) async {
final dir = await vaultPickFolder();
if (dir == null || !mounted) return;
var out = p.join(dir, e.name);
for (var i = 2; await File(out).exists(); i++) {
out = p.join(dir, '${p.basenameWithoutExtension(e.name)} ($i)${p.extension(e.name)}');
}
setState(() => _busy = true);
try {
final j = VaultExportJournal((await vaultViewRootOf()).path);
await _vault!.export(e, out, onTemp: j.add, onTempDone: j.remove);
_snack(trf('꺼냈습니다: {0}', [vDisplay(out)]));
} catch (err) {
if (mounted) setState(() => _busy = false);
if (err is VaultAuthException) return _broken(e, err);
_snack(trf('꺼내지 못했습니다: {0}', [err]));
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _remove(VaultEntry e) async {
final ok = await showDialog<bool>(
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) return;
await _vault!.remove(e);
if (mounted) setState(() {});
}
Future<void> _rename(VaultEntry e) async {
final name = await _askName(tr('이름 변경'), text: e.name);
if (name == null || name == e.name) return;
try {
await _vault!.rename(e, name);
} on StateError {
_snack(tr('같은 이름이 이미 있습니다'));
} on ArgumentError {
_snack(tr('쓸 수 없는 이름입니다'));
}
if (mounted) setState(() {});
}
/// 지금 보는 보관함 안 폴더 ("" = 맨 위)
String _cwd = '';
String _cwdLabel([String? f]) {
final x = f ?? _cwd;
return x.isEmpty ? tr('보관함 맨 위') : '${tr('보관함')} › ${x.split('/').join(' › ')}';
}
String _parentOf(String f) => f.contains('/') ? f.substring(0, f.lastIndexOf('/')) : '';
/// 이름 묻기 (글자 칸은 창이 닫히는 그림이 끝난 뒤 창과 함께 버린다 - 닫는 중에 버리면 오류)
Future<String?> _askName(String title, {String text = ''}) async {
final name = await showDialog<String>(context: context, builder: (_) => _NameDialog(title: title, text: text));
return name == null || name.isEmpty ? null : name;
}
/// 2단계: 보관함 안 새 폴더 (지금 폴더 안에)
Future<void> _mkdir() async {
final name = await _askName(tr('새 폴더'));
if (name == null) return;
if (!Vault.validName(name)) return _snack(tr('쓸 수 없는 이름입니다'));
final f = _cwd.isEmpty ? name : '$_cwd/$name';
if (_vault!.folders.contains(f)) return _snack(tr('같은 이름이 이미 있습니다'));
try {
await _vault!.mkdir(f);
} on StateError {
_snack(tr('같은 이름이 이미 있습니다'));
}
if (mounted) setState(() {});
}
/// 옮길 곳 고르기 (맨 위 + 모든 폴더). [except] 폴더와 그 안은 뺀다 (폴더를 자기 안으로 옮기지 않게)
Future<String?> _pickTarget({String? except, required String from}) {
final all = ['', ..._vault!.folders].where((f) => except == null || !(f == except || f.startsWith('$except/'))).toList();
return showDialog<String>(
context: context,
builder: (ctx) => SimpleDialog(
title: Text(tr('옮길 곳')),
children: [
for (final f in all)
ListTile(
dense: true,
enabled: f != from,
contentPadding: EdgeInsets.only(left: 16.0 + 16 * (f.isEmpty ? 0 : f.split('/').length), right: 16),
leading: Icon(f.isEmpty ? Icons.lock_outline : Icons.folder_outlined, size: 20),
title: Text(f.isEmpty ? tr('보관함 맨 위') : f.split('/').last),
subtitle: f == from ? Text(tr('지금 있는 곳')) : null,
onTap: () => Navigator.pop(ctx, f),
),
],
),
);
}
Future<void> _moveEntry(VaultEntry e) async {
final to = await _pickTarget(from: e.folder);
if (to == null || to == e.folder) return;
final moved = await _vault!.moveEntry(e, to);
if (moved.name != e.name) _snack(trf('같은 이름이 있어 "{0}" 로 옮겼습니다', [moved.name]));
if (mounted) setState(() {});
}
Future<void> _moveFolder(String f) async {
final to = await _pickTarget(except: f, from: _parentOf(f));
if (to == null || to == _parentOf(f)) return;
final name = f.split('/').last;
try {
await _vault!.moveFolder(f, to.isEmpty ? name : '$to/$name');
} on StateError {
_snack(tr('옮길 곳에 같은 이름이 이미 있습니다'));
}
if (mounted) setState(() {});
}
Future<void> _renameFolder(String f) async {
final name = await _askName(tr('이름 변경'), text: f.split('/').last);
if (name == null || name == f.split('/').last) return;
if (!Vault.validName(name)) return _snack(tr('쓸 수 없는 이름입니다'));
final parent = _parentOf(f);
try {
await _vault!.moveFolder(f, parent.isEmpty ? name : '$parent/$name');
} on StateError {
_snack(tr('같은 이름이 이미 있습니다'));
}
if (mounted) setState(() {});
}
Future<void> _removeFolder(String f) async {
final n = _vault!.filesUnder(f).length;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(tr('보관함에서 지우기')),
content: Text(trf('폴더 "{0}" 와 안의 파일 {1}개를 보관함에서 지웁니다. 되돌릴 수 없습니다 (먼저 [꺼내기] 를 하지 않았으면 내용이 사라집니다)',
[f.split('/').last, n])),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('지우기'))),
],
),
);
if (ok != true) return;
await _vault!.removeFolder(f);
if (mounted) setState(() {});
}
/// 폴더째 꺼내기 - 고른 곳에 같은 구조로 (이름이 겹치면 " (2)")
Future<void> _exportFolder(String f) async {
final dir = await vaultPickFolder();
if (dir == null || !mounted) return;
final name = f.split('/').last;
var root = p.join(dir, name);
for (var i = 2; await FileSystemEntity.type(root) != FileSystemEntityType.notFound; i++) {
root = p.join(dir, '$name ($i)');
}
setState(() => _busy = true);
var done = 0, failed = 0;
try {
String local(String folder) => p.joinAll([root, ...folder.substring(f.length).split('/').where((x) => x.isNotEmpty)]);
for (final sub in _vault!.folders.where((x) => x == f || x.startsWith('$f/'))) {
await Directory(local(sub)).create(recursive: true);
}
final files = _vault!.filesUnder(f);
final journal = VaultExportJournal((await vaultViewRootOf()).path);
for (var i = 0; i < files.length; i++) {
final e = files[i];
if (_vault == null) break;
if (mounted) setState(() => _progress = '${i + 1} / ${files.length} · ${e.name}');
try {
await _vault!.export(e, p.join(local(e.folder), e.name), onTemp: journal.add, onTempDone: journal.remove);
done++;
} catch (_) {
failed++;
}
}
_snack(failed == 0
? trf('꺼냈습니다: {0}', [vDisplay(root)])
: trf('{0}개를 꺼냈고 {1}개는 바뀌었거나 깨져 꺼내지 못했습니다: {2}', [done, failed, vDisplay(root)]));
} catch (err) {
_snack(trf('꺼내지 못했습니다: {0}', [err]));
} finally {
if (mounted) {
setState(() {
_busy = false;
_progress = null;
});
}
}
}
Future<void> _changePassword() async {
final a = TextEditingController(), b = TextEditingController();
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) => AlertDialog(
title: Text(tr('보관함 비밀번호 바꾸기')),
content: Column(mainAxisSize: MainAxisSize.min, children: [
TextField(controller: a, obscureText: true, decoration: InputDecoration(labelText: tr('새 비밀번호')), onChanged: (_) => set(() {})),
TextField(controller: b, obscureText: true, decoration: InputDecoration(labelText: tr('한 번 더')), onChanged: (_) => set(() {})),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: a.text.isNotEmpty && a.text == b.text ? () => Navigator.pop(ctx, true) : null, child: Text(tr('바꾸기'))),
],
),
),
);
if (ok == true && mounted) {
setState(() => _busy = true);
try {
await _vault!.changePassword(a.text, iterations: vaultIterationsForTest);
_snack(tr('보관함 비밀번호를 바꿨습니다'));
} finally {
if (mounted) setState(() => _busy = false);
}
}
// 창이 닫히는 그림이 끝난 뒤에 버린다 (닫는 중에 버리면 오류)
Future<void>.delayed(const Duration(seconds: 1), () {
a.dispose();
b.dispose();
});
}
/// [마스터로도 열기] 켜고 끄기 (마스터 비밀번호가 있어야 켬)
Future<void> _toggleMasterCopy(bool on) async {
final lock = MasterLock.instance;
if (lock == null) return;
if (on) {
if (!lock.hasMaster) {
_snack(tr('먼저 환경 설정 > 보안 에서 마스터 비밀번호를 정하세요'));
return;
}
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(tr('마스터로도 열기')),
content: Text(tr('보관함 열쇠 한 벌을 이 기기의 안전 저장소에 두고, 마스터 비밀번호로도 열 수 있게 합니다. '
'보관함 비밀번호를 잊어도 열 수 있지만, 마스터 비밀번호를 아는 사람도 열 수 있게 됩니다. '
'마스터를 초기화하면 이 길만 없어지고 보관함 비밀번호는 그대로입니다.')),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('켜기'))),
],
),
);
if (ok != true || !await SecretGate.pass(force: true)) return;
await lock.secrets.write(MasterLock.vaultCopyKey, base64.encode(_vault!.keyForMasterCopy));
} else {
await lock.secrets.delete(MasterLock.vaultCopyKey);
}
await c.updateSettings((x) => x.vaultMasterCopy = on);
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
_route ??= ModalRoute.of(context);
// 2단계: 보관함 안 폴더에서 ← 는 위 폴더로
return PopScope(
canPop: _stage != _Stage.open || _cwd.isEmpty,
onPopInvokedWithResult: (did, _) {
if (!did && _cwd.isNotEmpty) setState(() => _cwd = _parentOf(_cwd));
},
child: _scaffold(),
);
}
Widget _scaffold() => Scaffold(
appBar: AppBar(
title: Text(tr('암호 보관함')),
actions: [
if (_stage == _Stage.open) ...[
IconButton(tooltip: tr('잠그기'), icon: const Icon(Icons.lock_outline), onPressed: () => setState(_lock)),
PopupMenuButton<String>(
tooltip: tr('더 보기'),
onSelected: (w) => switch (w) {
'mkdir' => _mkdir(),
'pw' => _changePassword(),
'master' => _toggleMasterCopy(!c.settings.vaultMasterCopy),
_ => null,
},
itemBuilder: (_) => [
PopupMenuItem(value: 'mkdir', child: Text(tr('새 폴더'))),
PopupMenuItem(value: 'pw', child: Text(tr('보관함 비밀번호 바꾸기'))),
CheckedPopupMenuItem(value: 'master', checked: c.settings.vaultMasterCopy, child: Text(tr('마스터로도 열기'))),
],
),
],
],
),
body: Stack(children: [
switch (_stage) {
_Stage.checking => const Center(child: CircularProgressIndicator()),
_Stage.create => _createForm(),
_Stage.locked => _unlockForm(),
_Stage.opening => Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [
const CircularProgressIndicator(),
const SizedBox(height: 12),
Text(tr('보관함을 여는 중… (비밀번호 확인)')),
]),
),
_Stage.open => _list(),
},
if (_busy && _stage == _Stage.open)
Positioned(
left: 0,
right: 0,
top: 0,
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
const LinearProgressIndicator(),
if (_progress != null)
ColoredBox(
color: Theme.of(context).colorScheme.surface,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Text(_progress!, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12)),
),
),
]),
),
]),
);
Widget _note(String t, {Color? color}) =>
Padding(padding: const EdgeInsets.only(top: 6), child: Text(t, style: TextStyle(fontSize: 12.5, color: color ?? JjColors.textDim)));
Widget _createForm() => ListView(padding: const EdgeInsets.all(16), children: [
Text(tr('새 보관함 만들기'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
_note(trf('자리: {0}', [vDisplay(_dir?.path ?? '')])),
_note(tr('다른 앱도 이 폴더의 암호화된 파일을 볼 수 있습니다 (내용 · 이름은 보이지 않습니다)')),
_note(tr('이 폴더를 지우면 보관함도 사라집니다. 폴더를 통째로 복사하면 그대로 백업이 됩니다 (비밀번호로 열림)')),
_note(tr('비밀번호를 잊으면 되살릴 수 없습니다 (뒷문 없음)'), color: JjColors.danger),
const SizedBox(height: 12),
TextField(
controller: _pw, obscureText: true, decoration: InputDecoration(labelText: tr('보관함 비밀번호')), onChanged: (_) => setState(() {})),
TextField(controller: _pw2, obscureText: true, decoration: InputDecoration(labelText: tr('한 번 더')), onChanged: (_) => setState(() {})),
if (_pw2.text.isNotEmpty && _pw.text != _pw2.text) _note(tr('두 번 넣은 비밀번호가 다릅니다'), color: JjColors.danger),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
value: _understood,
onChanged: (v) => setState(() => _understood = v ?? false),
title: Text(tr('비밀번호를 잊으면 되살릴 수 없음을 압니다')),
),
if (_error != null) _note(_error!, color: JjColors.danger),
const SizedBox(height: 8),
FilledButton(
onPressed: !_busy && _understood && _pw.text.isNotEmpty && _pw.text == _pw2.text ? _create : null,
child: Text(tr('만들기')),
),
]);
Widget _unlockForm() {
final waiting = _waitUntil != null && DateTime.now().isBefore(_waitUntil!);
return ListView(padding: const EdgeInsets.all(16), children: [
Icon(Icons.lock_outline, size: 48, color: JjColors.textDim),
const SizedBox(height: 8),
TextField(
controller: _pw,
obscureText: true,
autofocus: true,
enabled: !waiting,
decoration: InputDecoration(labelText: tr('보관함 비밀번호')),
onSubmitted: (_) => _unlock(),
),
if (_error != null) _note(_error!, color: JjColors.danger),
const SizedBox(height: 12),
FilledButton(onPressed: waiting ? null : _unlock, child: Text(tr('열기'))),
if (c.settings.vaultMasterCopy)
TextButton(onPressed: waiting ? null : _unlockWithMaster, child: Text(tr('마스터 비밀번호로 열기'))),
_note(trf('자리: {0}', [vDisplay(_dir?.path ?? '')])),
]);
}
String _day(DateTime d) => '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
Widget _sortBox() => DropdownButton<String>(
value: _sort,
items: [
DropdownMenuItem(value: 'added', child: Text(tr('넣은 날'))),
DropdownMenuItem(value: 'name', child: Text(tr('이름'))),
],
onChanged: (v) => setState(() => _sort = v!),
);
Widget _list() {
final v = _vault!;
// 지운 · 옮긴 폴더 안에 있었으면 남은 가장 가까운 위 폴더로
while (_cwd.isNotEmpty && !v.folders.contains(_cwd)) {
_cwd = _parentOf(_cwd);
}
final list = v.filesIn(_cwd);
final dirs = v.subfolders(_cwd)..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
if (_sort == 'name') {
list.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
} else {
list.sort((a, b) => b.added.compareTo(a.added));
}
return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
// 관리자 지시 (10/21, 28 지적): 폴더 안에서는 경로 줄과 정렬 상자를 다른 줄로, 경로는 좁으면 앞을 "…" 로 (지금 폴더가 늘 보이게)
if (_cwd.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 16, 0),
child: Row(children: [
IconButton(
tooltip: tr('위 폴더로'), icon: const Icon(Icons.arrow_upward), onPressed: () => setState(() => _cwd = _parentOf(_cwd))),
Expanded(child: TailText(_cwdLabel(), style: const TextStyle(fontWeight: FontWeight.w600))),
]),
),
Padding(padding: const EdgeInsets.only(right: 16), child: Align(alignment: Alignment.centerRight, child: _sortBox())),
] else
Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 16, 0),
child: Row(children: [
const SizedBox(width: 8),
Expanded(
child: Text(tr('넣기는 파일 탐색기에서 파일 · 폴더를 길게 눌러 [보관함에 넣기]'), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
),
_sortBox(),
]),
),
if (list.isEmpty && dirs.isEmpty)
Expanded(child: Center(child: Text(_cwd.isEmpty ? tr('보관함이 비어 있습니다') : tr('이 폴더는 비어 있습니다')))),
if (list.isNotEmpty || dirs.isNotEmpty)
Expanded(
child: ListView.builder(
itemCount: dirs.length + list.length,
itemBuilder: (_, i) {
if (i < dirs.length) {
final f = _cwd.isEmpty ? dirs[i] : '$_cwd/${dirs[i]}';
final n = v.filesUnder(f).length;
return ListTile(
leading: Icon(Icons.folder, color: JjColors.accent),
title: Text(dirs[i], maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(trf('파일 {0}개', [n])),
onTap: _busy ? null : () => setState(() => _cwd = f),
trailing: PopupMenuButton<String>(
tooltip: tr('더 보기'),
enabled: !_busy,
onSelected: (w) => switch (w) {
'export' => _exportFolder(f),
'rename' => _renameFolder(f),
'move' => _moveFolder(f),
'remove' => _removeFolder(f),
_ => null,
},
itemBuilder: (_) => [
PopupMenuItem(value: 'export', child: Text(tr('꺼내기 (폴더째)'))),
PopupMenuItem(value: 'rename', child: Text(tr('이름 변경'))),
PopupMenuItem(value: 'move', child: Text(tr('옮기기'))),
PopupMenuItem(value: 'remove', child: Text(tr('보관함에서 지우기'))),
],
),
);
}
final e = list[i - dirs.length];
return ListTile(
leading: Icon(Icons.lock, color: JjColors.accent),
title: Text(e.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text('${formatSize(e.size)} · ${_day(e.added)}'),
onTap: _busy ? null : () => _view(e),
trailing: PopupMenuButton<String>(
tooltip: tr('더 보기'),
enabled: !_busy,
onSelected: (w) => switch (w) {
'view' => _view(e),
'export' => _export(e),
'rename' => _rename(e),
'move' => _moveEntry(e),
'remove' => _remove(e),
_ => null,
},
itemBuilder: (_) => [
PopupMenuItem(value: 'view', child: Text(tr('보기'))),
PopupMenuItem(value: 'export', child: Text(tr('꺼내기'))),
PopupMenuItem(value: 'rename', child: Text(tr('이름 변경'))),
PopupMenuItem(value: 'move', child: Text(tr('옮기기'))),
PopupMenuItem(value: 'remove', child: Text(tr('보관함에서 지우기'))),
],
),
);
},
),
),
]);
}
}
class _NameDialog extends StatefulWidget {
final String title, text;
const _NameDialog({required this.title, required this.text});
@override
State<_NameDialog> createState() => _NameDialogState();
}
class _NameDialogState extends State<_NameDialog> {
late final _ctl = TextEditingController(text: widget.text);
@override
void dispose() {
_ctl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => AlertDialog(
title: Text(widget.title),
content: TextField(controller: _ctl, autofocus: true, onSubmitted: (v) => Navigator.pop(context, v.trim())),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(context, _ctl.text.trim()), child: Text(tr('확인'))),
],
);
}
/// 2단계: 넣을 것 (폴더를 펼친 것). [failed] 읽지 못한 고른 것 → 오류 (그 원본은 지우지 않음)
class _AddPlan {
final files = <({String src, String folder, String top})>[];
final folders = <String>[];
final failed = <String, Object>{};
}
/// 보관함 그림을 메모리에서 바로 보는 보기 화면 원본 (파일로 풀지 않음)
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)));
}