JJ_MKVMaker/lib/ui/vault/vault_actions.dart

342 lines
16 KiB
Dart

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_dialog.dart' show confirmAction;
import '../player_page.dart';
import '../reader_page.dart' show openReader;
import '../theme.dart';
import 'vault_views.dart';
import 'vault_session.dart';
import '../../app/vault_location.dart';
/// 약속 (보관함 나누기): 보관함 안 파일 하나에 하는 일 - 보기 · 꺼내기 · 지우기 · 이름 변경 · 깨진 항목 알림.
/// - 입력: 화면 [context] · 작은 상태 [s] (열린 보관함 · 진행 막대 · 알림) · 항목 [e]
/// - 출력: 없음 (바뀐 것은 [VaultUiState.changed] 로 다시 그림)
/// - 오류: 던지지 않고 알림. 내용 파일이 빠지거나 바뀐 항목 ([VaultAuthException]) 은 [vaultBrokenEntry] 창으로
/// 그림은 메모리에서 바로 (파일로 풀지 않음)
const vaultImageMax = 256 * 1024 * 1024;
/// 보기: 그림은 메모리에서 바로 (파일로 풀지 않음 - 관리자 지시 10/21), 동영상 · 음악은 [session] 폴더에 잠깐 풀고 본 뒤 지운다
Future<void> vaultViewEntry(BuildContext context, AppController c, VaultUiState s, VaultViewSession session, VaultEntry e) async {
final ext = p.extension(e.name).replaceFirst('.', '').toLowerCase();
if (c.settings.reader.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<void>(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<void> vaultBrokenEntry(BuildContext context, VaultUiState s, VaultEntry e, Object err) async {
if (!context.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 || !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<void> vaultExportEntry(BuildContext context, VaultUiState s, VaultEntry e, VaultLocation where) async {
final dir = await where.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 where.viewRoot()).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<void> 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<void> 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<ReaderImage> load(int i, {required int maxWidth}) async =>
ReaderImage(provider: ResizeImage.resizeIfNeeded(maxWidth, null, MemoryImage(bytes)));
}
/// 약속 (보관함 나누기, 2단계): 보관함 안 폴더 일 - 새 폴더 · 옮기기 (파일 · 폴더) · 이름 변경 · 지우기 · 폴더째 꺼내기.
/// - 입력: 화면 [context] · 작은 상태 [s] (열린 보관함 · 지금 폴더 [VaultUiState.cwd] · 진행 막대 · 알림)
/// - 출력: 없음 (바뀐 것은 [VaultUiState.changed] 로 다시 그림)
/// - 오류: 같은 이름 · 쓸 수 없는 이름 · 꺼내지 못함은 알림으로 (던지지 않음)
/// 지금 폴더 안 새 폴더
Future<void> 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<void> 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<void> 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<void> 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<void> 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<void> vaultExportFolder(BuildContext context, VaultUiState s, String f, VaultLocation where) async {
final dir = await where.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 where.viewRoot()).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<void> 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<void> vaultToggleMasterCopy(BuildContext context, AppController c, VaultUiState s, bool on) async {
final lock = MasterLock.instance;
if (lock == null) return;
if (on) {
// M0: 안전 저장소를 읽지 못하면 마스터가 있는지 알 수 없다 - 정하라고 하지 않고 까닭을
if (lock.storeUnreadable) {
s.snack(tr('이 기기의 안전 저장소를 읽지 못해 지금은 켤 수 없습니다 (환경 설정 > 보안 [다시 읽기])'));
return;
}
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.vault.masterCopy = on);
if (context.mounted) s.changed();
}