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? value; @override void add(Digest data) => value = data; @override void close() {} } /// 해시 창 - 계산 (진행 · 취소) 뒤 MD5 · SHA-256, 붙여 넣은 값과 비교 Future 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( 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.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 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 createState() => _HexViewerPageState(); } class _HexViewerPageState extends State { final _data = []; int _size = 0; Object? _error; @override void initState() { super.initState(); _more(); } Future _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 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( 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; }