JJ_MKVMaker/lib/ui/file_tools.dart

280 lines
11 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:path/path.dart' as p;
import '../core/file_attributes.dart';
import '../core/file_ops.dart' show formatSize;
import '../l10n/tr.dart';
import 'theme.dart';
/// X-plore 비교 P3: 해시 · 16진 보기 · 폴더 속성 (읽기만 - 파일을 바꾸지 않는다)
/// 파일의 MD5 · SHA-256 (조각으로 읽어 큰 파일도 메모리를 쓰지 않음). [onProgress] 0~1, [cancelled] 면 null
Future<({String md5, String sha256})?> fileHashes(String path, {void Function(double)? onProgress, bool Function()? cancelled}) async {
final f = File(path);
final total = await f.length();
final m = _Sink(), s = _Sink();
final mIn = md5.startChunkedConversion(m), sIn = sha256.startChunkedConversion(s);
var done = 0;
await for (final chunk in f.openRead()) {
if (cancelled?.call() ?? false) return null;
mIn.add(chunk);
sIn.add(chunk);
done += chunk.length;
onProgress?.call(total == 0 ? 1 : done / total);
}
mIn.close();
sIn.close();
return (md5: '${m.value}', sha256: '${s.value}');
}
class _Sink implements Sink<Digest> {
Digest? value;
@override
void add(Digest data) => value = data;
@override
void close() {}
}
/// 해시 창 - 계산 (진행 · 취소) 뒤 MD5 · SHA-256, 붙여 넣은 값과 비교
Future<void> showFileHash(BuildContext context, String path) async {
var progress = 0.0;
var cancelled = false;
({String md5, String sha256})? result;
Object? error;
final compare = TextEditingController();
await showDialog<void>(
context: context,
builder: (ctx) => StatefulBuilder(builder: (ctx, set) {
if (result == null && error == null && progress == 0) {
progress = 0.0001;
fileHashes(path, onProgress: (v) {
if (ctx.mounted) set(() => progress = v);
}, cancelled: () => cancelled)
.then((r) {
if (ctx.mounted) set(() => result = r);
}, onError: (Object e) {
if (ctx.mounted) set(() => error = e);
});
}
final want = compare.text.trim().toLowerCase();
final match = result != null && want.isNotEmpty ? (want == result!.md5 || want == result!.sha256) : null;
Widget line(String name, String v) => Padding(
padding: const EdgeInsets.only(top: 6),
child: Row(children: [
SizedBox(width: 72, child: Text(name, style: const TextStyle(fontWeight: FontWeight.w600))),
Expanded(child: SelectableText(v, style: const TextStyle(fontFamily: 'monospace', fontSize: 12))),
IconButton(
tooltip: tr('복사'),
icon: const Icon(Icons.copy, size: 16),
onPressed: () => Clipboard.setData(ClipboardData(text: v)),
),
]),
);
return AlertDialog(
scrollable: true,
title: Text(trf('해시 · {0}', [p.basename(path)])),
content: SizedBox(
width: 560,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
if (error != null) Text(trf('읽지 못했습니다: {0}', [error]), style: const TextStyle(color: JjColors.danger)),
if (result == null && error == null) LinearProgressIndicator(value: progress),
if (result != null) ...[
line('MD5', result!.md5),
line('SHA-256', result!.sha256),
const SizedBox(height: 10),
TextField(
controller: compare,
decoration: InputDecoration(labelText: tr('비교할 값 붙여넣기 (받은 곳의 해시)'), isDense: true),
onChanged: (_) => set(() {}),
),
if (match != null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(match ? tr('같습니다 ✔') : tr('다릅니다 - 파일이 바뀌었거나 다른 파일입니다'),
style: TextStyle(color: match ? Colors.greenAccent : JjColors.danger)),
),
],
]),
),
actions: [
TextButton(
onPressed: () {
cancelled = true;
Navigator.pop(ctx);
},
child: Text(result == null && error == null ? tr('취소') : tr('닫기')),
),
],
);
}),
);
// 창이 닫히는 동안 (애니메이션) 글 칸이 아직 쓰므로 바로 dispose 하면 "used after being disposed" (15 캡처) - 닫힌 뒤에
Future<void>.delayed(const Duration(milliseconds: 500), compare.dispose);
}
/// 16진 보기: 한 줄 16 바이트 (위치 · 16진 · 글자). 큰 파일은 [더 읽기]
class HexViewerPage extends StatefulWidget {
final String path;
final int chunk;
const HexViewerPage({super.key, required this.path, this.chunk = 64 * 1024});
/// 한 줄 ([per] 바이트, 처음 16): "00000010 41 42 ... AB.."
static String line(int offset, List<int> bytes, {int per = 16}) {
final hex = StringBuffer();
for (var i = 0; i < per; i++) {
hex.write(i < bytes.length ? bytes[i].toRadixString(16).padLeft(2, '0') : ' ');
hex.write(i == 7 && per > 8 ? ' ' : ' ');
}
final text = String.fromCharCodes([for (final b in bytes) b >= 0x20 && b < 0x7f ? b : 0x2e]);
return '${offset.toRadixString(16).padLeft(8, '0')} $hex $text';
}
@override
State<HexViewerPage> createState() => _HexViewerPageState();
}
class _HexViewerPageState extends State<HexViewerPage> {
final _data = <int>[];
int _size = 0;
Object? _error;
@override
void initState() {
super.initState();
_more();
}
Future<void> _more() async {
try {
final f = File(widget.path);
_size = await f.length();
final to = (_data.length + widget.chunk).clamp(0, _size);
await for (final x in f.openRead(_data.length, to)) {
_data.addAll(x);
}
} catch (e) {
_error = e;
}
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
// 좁은 화면 (폰 세로) 은 한 줄 8 바이트 - 16 이면 끝의 글자 칸이 화면 밖으로 잘렸다 (15 캡처, 412 폭)
final per = MediaQuery.sizeOf(context).width < 560 ? 8 : 16;
final rows = (_data.length + per - 1) ~/ per;
return Scaffold(
appBar: AppBar(title: Text(trf('16진 보기 · {0}', [p.basename(widget.path)]), maxLines: 1, overflow: TextOverflow.ellipsis)),
body: _error != null
? Center(child: Text(trf('읽지 못했습니다: {0}', [_error])))
: Column(children: [
Expanded(
child: Scrollbar(
child: ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: rows,
itemExtent: 18,
itemBuilder: (_, i) => Text(
HexViewerPage.line(i * per, _data.sublist(i * per, (i * per + per).clamp(0, _data.length)), per: per),
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
maxLines: 1,
softWrap: false,
),
),
),
),
if (_data.length < _size)
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.all(6),
child: Row(children: [
Expanded(
child: Text(trf('{0} / {1} 읽음', [formatSize(_data.length), formatSize(_size)]),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
),
FilledButton.tonal(onPressed: _more, child: Text(tr('더 읽기'))),
]),
),
),
]),
);
}
}
/// 폴더 속성: 안의 파일 · 폴더 수, 크기, 만든 · 바뀐 때 (링크 · junction 은 따라가지 않음)
Future<({int files, int folders, int bytes, int links})> folderStats(String dir, {bool Function()? cancelled}) async {
var files = 0, folders = 0, bytes = 0, links = 0;
await for (final e in Directory(dir).list(recursive: true, followLinks: false).handleError((_) {})) {
if (cancelled?.call() ?? false) break;
if (e is Link) {
links++;
} else if (e is Directory) {
folders++;
} else if (e is File) {
files++;
try {
bytes += await e.length();
} catch (_) {}
}
}
return (files: files, folders: folders, bytes: bytes, links: links);
}
Future<void> showFolderProperties(BuildContext context, String dir) async {
var cancelled = false;
final stat = await FileStat.stat(dir);
final attrs = readFileAttributes(dir);
if (!context.mounted) return;
String date(DateTime d) =>
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')} ${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}';
final future = folderStats(dir, cancelled: () => cancelled);
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(trf('폴더 속성 · {0}', [p.basename(dir)])),
content: SizedBox(
width: 480,
child: FutureBuilder(
future: future,
builder: (_, s) {
final r = s.data;
return Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
SelectableText(dir, style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(height: 8),
if (r == null) const LinearProgressIndicator(),
if (r != null) ...[
Text(trf('크기: {0} ({1} 바이트)', [formatSize(r.bytes), r.bytes])),
Text(trf('파일 {0}개 · 폴더 {1}개', [r.files, r.folders])),
if (r.links > 0) Text(trf('링크 · junction {0}개 (따라가지 않고 세지 않음)', [r.links])),
],
const SizedBox(height: 6),
// 관리자 지시 10/18: 만든 때 · 숨김 · 읽기 전용 (보기만)
if (attrs?.created != null) Text(trf('만든 때: {0}', [date(attrs!.created!)])),
Text(trf('바뀐 때: {0}', [date(stat.modified)])),
Text(trf('열어 본 때: {0}', [date(stat.accessed)])),
Text(trf('속성: {0}', [attrs == null || attrs.labels.isEmpty ? tr('없음') : attrs.labels.join(' · ')])),
]);
},
),
),
actions: [
TextButton(
onPressed: () {
cancelled = true;
Navigator.pop(ctx);
},
child: Text(tr('닫기')),
),
],
),
);
cancelled = true;
}