import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:path/path.dart' as p; import '../../app/app_controller.dart'; import '../../app/master_lock.dart'; 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/vfs.dart' show vDisplay; import '../../l10n/tr.dart'; import '../confirm.dart' show confirmAction; import '../player_page.dart'; import '../reader_page.dart' show openReader; import '../theme.dart'; import 'vault_views.dart'; import 'vault_session.dart'; /// 약속 (보관함 나누기): 보관함 안 파일 하나에 하는 일 - 보기 · 꺼내기 · 지우기 · 이름 변경 · 깨진 항목 알림. /// - 입력: 화면 [context] · 작은 상태 [s] (열린 보관함 · 진행 막대 · 알림) · 항목 [e] /// - 출력: 없음 (바뀐 것은 [VaultUiState.changed] 로 다시 그림) /// - 오류: 던지지 않고 알림. 내용 파일이 빠지거나 바뀐 항목 ([VaultAuthException]) 은 [vaultBrokenEntry] 창으로 /// 그림은 메모리에서 바로 (파일로 풀지 않음) const vaultImageMax = 256 * 1024 * 1024; /// 보기: 그림은 메모리에서 바로 (파일로 풀지 않음 - 관리자 지시 10/21), 동영상 · 음악은 [session] 폴더에 잠깐 풀고 본 뒤 지운다 Future vaultViewEntry(BuildContext context, AppController c, VaultUiState s, VaultViewSession session, VaultEntry e) async { final ext = p.extension(e.name).replaceFirst('.', '').toLowerCase(); if (c.settings.imageExts.contains(ext) && e.size <= vaultImageMax) { try { s.setBusy(true); final bytes = await s.vault!.readBytes(e, maxBytes: vaultImageMax); if (context.mounted) s.setBusy(false); if (!context.mounted) return; await openReader(context, c, VaultImageSource(e.name, bytes)); } catch (err) { if (context.mounted) s.setBusy(false); if (err is VaultAuthException) { if (context.mounted) return vaultBrokenEntry(context, s, e, err); return; } s.snack(trf('열 수 없습니다: {0}', [err])); } return; } if (!isVideoFile(e.name) && !isAudioFile(e.name)) { s.snack(tr('이 종류는 앱 안에서 볼 수 없습니다 - [꺼내기] 로 꺼내서 보세요')); return; } try { // 동영상 · 음악: 앱 전용 폴더 (jj_vault_view_…) 에 잠깐 풀고, 닫으면 · 잠그면 · 다음에 켤 때 지운다 final d = await session.dir(); final out = p.join(d.path, 'v_${DateTime.now().microsecondsSinceEpoch}${p.extension(e.name)}'); s.setBusy(true); await s.vault!.export(e, out); if (context.mounted) s.setBusy(false); if (!context.mounted) return; { final create = c.services.createMediaPlayer; if (create != null) { await Navigator.of(context).push(MaterialPageRoute(builder: (_) => PlayerPage(c: c, player: create(), files: [out]))); } } // 본 뒤 풀어 둔 것은 지운다 await File(out).delete().catchError((_) => File(out)); } catch (err) { if (context.mounted) s.setBusy(false); if (err is VaultAuthException) { if (context.mounted) return vaultBrokenEntry(context, s, e, err); return; } s.snack(trf('열 수 없습니다: {0}', [err])); } } /// 내용 파일이 없거나 바뀐 항목 (관리자 지시 10/21, 28 지적): 이유 · [자세히] (원문) · [목록에서 빼기] (확인) Future vaultBrokenEntry(BuildContext context, VaultUiState s, VaultEntry e, Object err) async { if (!context.mounted) return; var detail = false; final drop = await showDialog( context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, set) => AlertDialog( scrollable: true, icon: Icon(Icons.report_gmailerrorred_outlined, color: JjColors.danger), title: Text(trf('"{0}" 을 꺼낼 수 없습니다', [e.name])), content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(tr('보관함 파일이 바뀌었거나 빠졌습니다 - 이 항목은 꺼낼 수 없습니다 (보관함 폴더를 다른 곳에서 바꿨는지 확인하세요)')), TextButton(onPressed: () => set(() => detail = !detail), child: Text(detail ? tr('자세히 닫기') : tr('자세히'))), if (detail) SelectableText('${e.blob}.jjv · $err', style: TextStyle(fontSize: 12, color: JjColors.textDim, fontFamily: 'Consolas')), ]), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('목록에서 빼기'))), FilledButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('닫기'))), ], ), ), ); if (drop != true || !context.mounted) return; final ok = await confirmAction(context, title: tr('목록에서 빼기'), body: trf('"{0}" 을 보관함 목록에서 뺍니다. 남아 있는 그 내용 파일도 지웁니다 (꺼낼 수 없는 항목이라 잃는 것은 없습니다)', [e.name]), ok: tr('빼기'), danger: false); if (!ok || s.vault == null) return; await s.vault!.remove(e); if (context.mounted) s.changed(); } /// 꺼내기: [pickFolder] 로 고른 곳에 (이름이 겹치면 " (2)"). 쓰는 동안의 평문 임시 파일은 꺼내기 기록에 남긴다 Future vaultExportEntry(BuildContext context, VaultUiState s, VaultEntry e, Future Function() pickFolder) async { final dir = await pickFolder(); if (dir == null || !context.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)}'); } s.setBusy(true); try { final j = VaultExportJournal((await vaultViewRootOf()).path); await s.vault!.export(e, out, onTemp: j.add, onTempDone: j.remove); s.snack(trf('꺼냈습니다: {0}', [vDisplay(out)])); } catch (err) { if (context.mounted) s.setBusy(false); if (err is VaultAuthException) { if (context.mounted) return vaultBrokenEntry(context, s, e, err); return; } s.snack(trf('꺼내지 못했습니다: {0}', [err])); } finally { if (context.mounted) s.setBusy(false); } } /// 보관함에서 지우기 (확인 창 - 되돌릴 수 없음) Future vaultRemoveEntry(BuildContext context, VaultUiState s, VaultEntry e) async { final ok = await confirmAction(context, title: tr('보관함에서 지우기'), body: trf('"{0}" 을 보관함에서 지웁니다. 되돌릴 수 없습니다 (먼저 [꺼내기] 를 하지 않았으면 내용이 사라집니다)', [e.name]), ok: tr('지우기'), danger: false); if (!ok) return; await s.vault!.remove(e); if (context.mounted) s.changed(); } /// 이름 변경 (같은 이름 · 쓸 수 없는 이름은 알림) Future vaultRenameEntry(BuildContext context, VaultUiState s, VaultEntry e) async { final name = await askVaultName(context, tr('이름 변경'), text: e.name); if (name == null || name == e.name) return; try { await s.vault!.rename(e, name); } on StateError { s.snack(tr('같은 이름이 이미 있습니다')); } on ArgumentError { s.snack(tr('쓸 수 없는 이름입니다')); } if (context.mounted) s.changed(); } /// 보관함 그림을 메모리에서 바로 보는 보기 화면 원본 (파일로 풀지 않음) 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))); } /// 약속 (보관함 나누기, 2단계): 보관함 안 폴더 일 - 새 폴더 · 옮기기 (파일 · 폴더) · 이름 변경 · 지우기 · 폴더째 꺼내기. /// - 입력: 화면 [context] · 작은 상태 [s] (열린 보관함 · 지금 폴더 [VaultUiState.cwd] · 진행 막대 · 알림) /// - 출력: 없음 (바뀐 것은 [VaultUiState.changed] 로 다시 그림) /// - 오류: 같은 이름 · 쓸 수 없는 이름 · 꺼내지 못함은 알림으로 (던지지 않음) /// 지금 폴더 안 새 폴더 Future vaultMakeFolder(BuildContext context, VaultUiState s) async { final name = await askVaultName(context, tr('새 폴더')); if (name == null) return; if (!Vault.validName(name)) return s.snack(tr('쓸 수 없는 이름입니다')); final f = vaultJoin(s.cwd, name); if (s.vault!.folders.contains(f)) return s.snack(tr('같은 이름이 이미 있습니다')); try { await s.vault!.mkdir(f); } on StateError { s.snack(tr('같은 이름이 이미 있습니다')); } if (context.mounted) s.changed(); } /// 파일 하나를 다른 폴더로 (같은 이름이 있으면 보관함이 붙인 새 이름을 알림) Future vaultMoveEntry(BuildContext context, VaultUiState s, VaultEntry e) async { final to = await pickVaultTarget(context, s.vault!.folders, from: e.folder); if (to == null || to == e.folder) return; final moved = await s.vault!.moveEntry(e, to); if (moved.name != e.name) s.snack(trf('같은 이름이 있어 "{0}" 로 옮겼습니다', [moved.name])); if (context.mounted) s.changed(); } /// 폴더째 옮기기 (자기 안으로는 고를 수 없음) Future vaultMoveFolder(BuildContext context, VaultUiState s, String f) async { final to = await pickVaultTarget(context, s.vault!.folders, except: f, from: vaultParentOf(f)); if (to == null || to == vaultParentOf(f)) return; final name = f.split('/').last; try { await s.vault!.moveFolder(f, vaultJoin(to, name)); } on StateError { s.snack(tr('옮길 곳에 같은 이름이 이미 있습니다')); } if (context.mounted) s.changed(); } /// 폴더 이름 변경 Future vaultRenameFolder(BuildContext context, VaultUiState s, String f) async { final name = await askVaultName(context, tr('이름 변경'), text: f.split('/').last); if (name == null || name == f.split('/').last) return; if (!Vault.validName(name)) return s.snack(tr('쓸 수 없는 이름입니다')); final parent = vaultParentOf(f); try { await s.vault!.moveFolder(f, vaultJoin(parent, name)); } on StateError { s.snack(tr('같은 이름이 이미 있습니다')); } if (context.mounted) s.changed(); } /// 폴더와 안의 파일을 보관함에서 지우기 (확인 창 - 되돌릴 수 없음) Future vaultRemoveFolder(BuildContext context, VaultUiState s, String f) async { final n = s.vault!.filesUnder(f).length; final ok = await confirmAction(context, title: tr('보관함에서 지우기'), body: trf('폴더 "{0}" 와 안의 파일 {1}개를 보관함에서 지웁니다. 되돌릴 수 없습니다 (먼저 [꺼내기] 를 하지 않았으면 내용이 사라집니다)', [f.split('/').last, n]), ok: tr('지우기'), danger: false); if (!ok) return; await s.vault!.removeFolder(f); if (context.mounted) s.changed(); } /// 폴더째 꺼내기 - [pickFolder] 로 고른 곳에 같은 구조로 (이름이 겹치면 " (2)"). 깨진 파일은 세어 알림 (나머지는 꺼냄) Future vaultExportFolder(BuildContext context, VaultUiState s, String f, Future Function() pickFolder) async { final dir = await pickFolder(); if (dir == null || !context.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)'); } s.setBusy(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 s.vault!.folders.where((x) => x == f || x.startsWith('$f/'))) { await Directory(local(sub)).create(recursive: true); } final files = s.vault!.filesUnder(f); final journal = VaultExportJournal((await vaultViewRootOf()).path); for (var i = 0; i < files.length; i++) { final e = files[i]; if (s.vault == null) break; if (context.mounted) s.setProgress('${i + 1} / ${files.length} · ${e.name}'); try { await s.vault!.export(e, p.join(local(e.folder), e.name), onTemp: journal.add, onTempDone: journal.remove); done++; } catch (_) { failed++; } } s.snack(failed == 0 ? trf('꺼냈습니다: {0}', [vDisplay(root)]) : trf('{0}개를 꺼냈고 {1}개는 바뀌었거나 깨져 꺼내지 못했습니다: {2}', [done, failed, vDisplay(root)])); } catch (err) { s.snack(trf('꺼내지 못했습니다: {0}', [err])); } finally { if (context.mounted) s.setBusyProgress(false, null); } } /// 약속 (보관함 나누기): 열린 보관함의 열쇠 일 - 비밀번호 바꾸기 · [마스터로도 열기] 켜고 끄기. /// - 입력: 화면 [context] · 작은 상태 [s] (열린 보관함 · 진행 막대 · 알림) /// - 출력: 없음. 오류: [vaultChangePassword] 는 보관함이 쓰지 못한 오류를 그대로 던진다 (진행 막대는 끔) /// 보관함 비밀번호 바꾸기 (새 비밀번호 두 번 → PBKDF2 [iterations] 번으로 머리 다시 씀) Future vaultChangePassword(BuildContext context, VaultUiState s, {required int iterations}) async { final pw = await askVaultNewPassword(context); if (pw != null && context.mounted) { s.setBusy(true); try { await s.vault!.changePassword(pw, iterations: iterations); s.snack(tr('보관함 비밀번호를 바꿨습니다')); } finally { if (context.mounted) s.setBusy(false); } } } /// [마스터로도 열기] 켜고 끄기 (마스터 비밀번호가 있어야 켬). 켜면 보관함 열쇠 한 벌을 안전 저장소에, 끄면 지운다 Future vaultToggleMasterCopy(BuildContext context, AppController c, VaultUiState s, bool on) async { final lock = MasterLock.instance; if (lock == null) return; if (on) { if (!lock.hasMaster) { s.snack(tr('먼저 환경 설정 > 보안 에서 마스터 비밀번호를 정하세요')); return; } final ok = await confirmAction(context, title: tr('마스터로도 열기'), body: tr('보관함 열쇠 한 벌을 이 기기의 안전 저장소에 두고, 마스터 비밀번호로도 열 수 있게 합니다. ' '보관함 비밀번호를 잊어도 열 수 있지만, 마스터 비밀번호를 아는 사람도 열 수 있게 됩니다. ' '마스터를 초기화하면 이 길만 없어지고 보관함 비밀번호는 그대로입니다.'), ok: tr('켜기'), danger: false); if (!ok || !await SecretGate.pass(force: true)) return; await lock.secrets.write(MasterLock.vaultCopyKey, base64.encode(s.vault!.keyForMasterCopy)); } else { await lock.secrets.delete(MasterLock.vaultCopyKey); } await c.updateSettings((x) => x.vaultMasterCopy = on); if (context.mounted) s.changed(); }