342 lines
16 KiB
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();
|
|
}
|