280 lines
11 KiB
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: 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('비교할 해시'), helperText: tr('받은 곳에서 알려 준 해시를 붙여넣으세요'), helperMaxLines: 3, 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: 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: 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;
|
|
}
|