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/secret_gate.dart'; import '../core/vault_crypto.dart'; import '../core/vault_store.dart'; import '../l10n/tr.dart'; import 'vault/vault_actions.dart'; import 'vault/vault_session.dart'; import 'vault/vault_views.dart'; /// X-plore 비교 5-2 암호 보관함 화면 (사용자 결정 10/21, 관리자 결정 - 설계안 위키 프로젝트/암호 보관함 · 루트 설계안 (10-21)) /// 약속 (보관함 나누기): 이 화면은 단계 (확인 · 만들기 · 잠김 · 여는 중 · 열림) 와 열고 잠그기만 맡고, /// 넣기 · 파일 · 폴더 · 열쇠 일은 lib/ui/vault/ 의 모듈을 부른다 (작은 상태 [VaultUiState] 를 넘김) Future openVault(BuildContext context, AppController c, {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). 시험에서 바꿈 Future Function() vaultDirOf = () async { if (Platform.isAndroid) { final root = await const MethodChannel('jj_mkvmaker/android').invokeMethod('storageRoot') ?? '/storage/emulated/0'; return Directory(p.join(root, 'Download', 'JJ_MKVMaker', 'vault')); } return Directory(p.join((await getApplicationDocumentsDirectory()).path, 'JJ_MKVMaker', 'vault')); }; /// 꺼낼 폴더 고르기 (시험에서 바꿈) Future Function() vaultPickFolder = () => FilePicker.getDirectoryPath(); /// 시험: PBKDF2 반복 수 (새 보관함 · 비밀번호 바꾸기) int vaultIterationsForTest = VaultCrypto.defaultIterations; class VaultPage extends StatefulWidget { final AppController c; /// 열면서 넣을 파일 (탐색기 [보관함에 넣기]) final List add; /// [원본 지우기 (이동)] 일 때 탐색기의 지우기 (확인 창 · [permanent] 가 아니면 휴지통) final Future Function(List paths, {required bool permanent})? deleteOriginals; const VaultPage({super.key, required this.c, this.add = const [], this.deleteOriginals}); @override State createState() => _VaultPageState(); } enum _Stage { checking, create, locked, opening, open } class _VaultPageState extends State with WidgetsBindingObserver { _Stage _stage = _Stage.checking; Directory? _dir; String? _error; int _fails = 0; DateTime? _waitUntil, _hiddenAt; final _pw = TextEditingController(), _pw2 = TextEditingController(); bool _understood = false; late List _pendingAdd = [...widget.add]; String _sort = 'added'; /// 모듈에 넘기는 작은 상태 (열린 보관함 · 진행 막대 · 지금 폴더 · 알림) late final _s = VaultUiState( changed: () { if (mounted) setState(() {}); }, snack: _snack, ); /// 이번에 연 동안 잠깐 푼 파일을 두는 앱 전용 폴더 (jj_vault_view_…) final _session = VaultViewSession(); 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 && _s.vault != null && DateTime.now().difference(at).inSeconds >= c.settings.vaultAutoLockSeconds) { setState(_lock); } } } /// 이 화면의 길 (잠글 때 위에 열린 보기 화면을 닫으려고) ModalRoute? _route; /// 잠금: 열쇠를 버리고, 위에 열린 보기 (그림 · 플레이어) 를 닫고, 잠깐 푼 파일을 지운다 (관리자 지시 10/21) void _lock({bool closeViews = true}) { _s.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(_session.clear()); } Future _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 _create() async { if (_pw.text.isEmpty || _pw.text != _pw2.text || !_understood) return; setState(() { _s.busy = true; _stage = _Stage.opening; }); try { final v = await Vault.create(_dir!, _pw.text, iterations: vaultIterationsForTest); _pw.clear(); _pw2.clear(); if (!mounted) return; setState(() { _s.vault = v; _stage = _Stage.open; }); await _addPending(); } catch (e) { if (mounted) { setState(() { _stage = _Stage.create; _error = trf('보관함을 만들지 못했습니다: {0}', [e]); }); } } finally { if (mounted) setState(() => _s.busy = false); } } Future _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(() { _s.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 _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(() { _s.vault = v; _stage = _Stage.open; }); await _addPending(); } catch (e) { if (mounted) { setState(() { _stage = _Stage.locked; _error = trf('보관함을 열지 못했습니다: {0}', [e]); }); } } } /// 탐색기에서 고른 것 넣기 (한 번만 - 넣을 것은 비움) Future _addPending() async { final paths = _pendingAdd; if (paths.isEmpty || _s.vault == null) return; _pendingAdd = []; await vaultAddFlow(context, _s, paths, thumbTemp: c.services.storage.tempDirectory, deleteOriginals: widget.deleteOriginals); } void _onFolder(String f, String action) => switch (action) { 'export' => vaultExportFolder(context, _s, f, vaultPickFolder), 'rename' => vaultRenameFolder(context, _s, f), 'move' => vaultMoveFolder(context, _s, f), 'remove' => vaultRemoveFolder(context, _s, f), _ => null, }; void _onEntry(VaultEntry e, String action) => switch (action) { 'view' => vaultViewEntry(context, c, _s, _session, e), 'export' => vaultExportEntry(context, _s, e, vaultPickFolder), 'rename' => vaultRenameEntry(context, _s, e), 'move' => vaultMoveEntry(context, _s, e), 'remove' => vaultRemoveEntry(context, _s, e), _ => null, }; @override Widget build(BuildContext context) { _route ??= ModalRoute.of(context); // 2단계: 보관함 안 폴더에서 ← 는 위 폴더로 return PopScope( canPop: _stage != _Stage.open || _s.cwd.isEmpty, onPopInvokedWithResult: (did, _) { if (!did && _s.cwd.isNotEmpty) setState(() => _s.cwd = vaultParentOf(_s.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( tooltip: tr('더 보기'), onSelected: (w) => switch (w) { 'mkdir' => vaultMakeFolder(context, _s), 'pw' => vaultChangePassword(context, _s, iterations: vaultIterationsForTest), 'master' => vaultToggleMasterCopy(context, c, _s, !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 => VaultCreateForm( dirPath: _dir?.path ?? '', pw: _pw, pw2: _pw2, understood: _understood, busy: _s.busy, error: _error, onChanged: () => setState(() {}), onUnderstood: (v) => setState(() => _understood = v), onCreate: _create, ), _Stage.locked => VaultUnlockForm( dirPath: _dir?.path ?? '', pw: _pw, waiting: _waitUntil != null && DateTime.now().isBefore(_waitUntil!), error: _error, masterCopy: c.settings.vaultMasterCopy, onUnlock: _unlock, onUnlockWithMaster: _unlockWithMaster, ), _Stage.opening => Center( child: Column(mainAxisSize: MainAxisSize.min, children: [ const CircularProgressIndicator(), const SizedBox(height: 12), Text(tr('보관함을 여는 중… (비밀번호 확인)')), ]), ), _Stage.open => _list(), }, if (_s.busy && _stage == _Stage.open) Positioned( left: 0, right: 0, top: 0, child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const LinearProgressIndicator(), if (_s.progress != null) ColoredBox( color: Theme.of(context).colorScheme.surface, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: Text(_s.progress!, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12)), ), ), ]), ), ]), ); Widget _list() { final v = _s.vault!; // 지운 · 옮긴 폴더 안에 있었으면 남은 가장 가까운 위 폴더로 while (_s.cwd.isNotEmpty && !v.folders.contains(_s.cwd)) { _s.cwd = vaultParentOf(_s.cwd); } return VaultListView( vault: v, cwd: _s.cwd, sort: _sort, busy: _s.busy, onCwd: (f) => setState(() => _s.cwd = f), onSort: (x) => setState(() => _sort = x), onFolder: _onFolder, onEntry: _onEntry, ); } }