import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show MethodChannel; import 'package:path/path.dart' as p; import '../app/app_controller.dart'; import '../app/copy_center.dart' show diskSpace; import '../core/archive_ops.dart'; import '../core/file_ops.dart'; import '../core/vfs.dart'; import '../l10n/tr.dart'; import 'app_actions.dart' show isCompact; import 'theme.dart'; /// 86: 동봉한 7-Zip. 없으면 null /// - Windows: 실행 파일 옆 7zip\7z.exe (개발 중에는 third_party 의 것) /// - Android: 네이티브 라이브러리 폴더의 lib7zz.so (tool/build_7zz_android.sh, APK 의 jniLibs - rsync 와 같은 방식) Future bundledSevenZip() async { if (Platform.isAndroid) { try { final dir = await const MethodChannel('jj_mkvmaker/android').invokeMethod('nativeLibDir'); if (dir != null && File(p.join(dir, 'lib7zz.so')).existsSync()) return p.join(dir, 'lib7zz.so'); } catch (_) {} return null; } if (!Platform.isWindows) return null; for (final f in [ p.join(p.dirname(Platform.resolvedExecutable), '7zip', '7z.exe'), p.join(Directory.current.path, 'third_party', '7zip', 'windows', '7z.exe'), ]) { if (File(f).existsSync()) return f; } return null; } /// 비밀번호 묻기 (보이지 않게 · 작업 기록에 남기지 않음). 취소하면 null Future askArchivePassword(BuildContext context, String name, {bool wrong = false}) async { final ctl = TextEditingController(); try { return await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(tr('비밀번호')), content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(trf('"{0}" 은(는) 비밀번호가 걸려 있습니다.', [name])), if (wrong) Text(tr('비밀번호가 틀렸습니다'), style: TextStyle(color: JjColors.danger)), TextField( controller: ctl, autofocus: true, obscureText: true, enableSuggestions: false, autocorrect: false, decoration: InputDecoration(labelText: tr('비밀번호'), isDense: true), onSubmitted: (v) => Navigator.pop(ctx, v), ), ]), actions: [ TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('취소'))), FilledButton(onPressed: () => Navigator.pop(ctx, ctl.text), child: Text(tr('확인'))), ], ), ); } finally { ctl.dispose(); } } /// 진행 창 (취소 단추). [run] 은 진행률 (0~1, 모르면 null) 을 [set] 으로 알리고, 취소는 [cancelled] 로 본다 Future _withProgress(BuildContext context, String title, Future Function(void Function(double?) set, bool Function() cancelled) run) async { final v = ValueNotifier(null); var stop = false; final nav = Navigator.of(context); var open = true; unawaited(showDialog( context: context, barrierDismissible: false, builder: (ctx) => AlertDialog( title: Text(title), content: SizedBox( width: 380, child: ValueListenableBuilder( valueListenable: v, builder: (_, x, _) => Column(mainAxisSize: MainAxisSize.min, children: [ LinearProgressIndicator(value: x), const SizedBox(height: 6), Text(x == null ? tr('준비하는 중') : '${(x * 100).round()}%', style: const TextStyle(fontSize: 12)), ]), ), ), actions: [TextButton(onPressed: () => stop = true, child: Text(tr('취소')))], ), ).whenComplete(() => open = false)); try { return await run((x) => v.value = x, () => stop); } finally { if (open) nav.pop(); } } /// 86: 압축 풀기 (ZIP · 7z · RAR). 목록을 먼저 읽어 검사 (밖으로 나가는 경로 · 링크 · 크기 · 비밀번호) 한 뒤 확인 창 → 풀기. /// [names] 가 있으면 그 항목만. 푼 폴더를 돌려준다 (취소 · 실패면 null) Future extractArchiveFlow(BuildContext context, AppController c, String path, {String? otherDir, List names = const []}) async { final kind = archiveKindOf(path); if (kind == null) return null; final messenger = ScaffoldMessenger.maybeOf(context); void say(String t) => messenger?.showSnackBar(SnackBar(content: Text(t))); final local = isDav(path) ? await vLocalCopy(path, await c.services.storage.tempDirectory()) : path; if (!context.mounted) return null; // 7z · RAR: 동봉한 7-Zip 이 있어야 (Android 는 아직 - 막지 않고 다른 앱으로 안내) final exe = kind == ArchiveKind.zip ? null : await bundledSevenZip(); if (!context.mounted) return null; if (kind != ArchiveKind.zip && exe == null) { final open = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(tr('압축 풀기')), content: Text(tr('이 기기에서는 아직 7z · RAR 을 앱에서 풀 수 없습니다. 압축을 푸는 다른 앱으로 열어 주세요.')), actions: [ TextButton(onPressed: () => Navigator.pop(ctx), child: Text(tr('닫기'))), FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('다른 앱으로 열기'))), ], ), ); if (open == true) await c.services.shell.openWith(local, choose: true); return null; } // 목록 (머리까지 암호가 걸린 7z 는 비밀번호부터) String? password; List entries; while (true) { try { entries = kind == ArchiveKind.zip ? await listZip(local) : await list7z(exe!, local, password: password); break; } on ArchivePasswordException catch (e) { if (!context.mounted) return null; password = await askArchivePassword(context, vBasename(path), wrong: e.wrong); if (password == null) return null; } catch (e) { say(trf('압축을 읽지 못했습니다: {0}', [e])); return null; } } final plan = planExtract(entries, names: names); if (!context.mounted) return null; // 풀 곳 (WebDAV 폴더로는 풀지 않음 - 이 기기의 폴더만) final here = vDirname(path); final base = vBasename(path).replaceAll(RegExp(r'\.[^.]+$'), ''); final choices = <(String, String)>[ if (!isDav(here)) (vJoin(here, base), trf('압축 옆 새 폴더 "{0}"', [base])), if (!isDav(here)) (here, tr('압축이 있는 폴더')), if (otherDir != null && !isDav(otherDir)) (otherDir, tr('다른 창 폴더')), ]; if (choices.isEmpty) { say(tr('WebDAV · SFTP 폴더로는 풀 수 없습니다. 이 기기의 폴더에서 풀어 주세요.')); return null; } var dest = choices.first.$1; var conflict = switch (c.settings.copy.conflict) { 'overwrite' => NameConflict.overwrite, 'skip' => NameConflict.skip, _ => NameConflict.rename }; Future free(String d) async { var x = d; while (!Directory(x).existsSync() && p.dirname(x) != x) { x = p.dirname(x); } return (await diskSpace(x))?.$1; } final frees = {for (final (d, _) in choices) d: await free(d)}; if (!context.mounted) return null; final go = await showDialog( context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, set) { final f = frees[dest]; final short = f != null && plan.totalSize > f; return AlertDialog( scrollable: true, title: Text(trf('압축 풀기 · {0}', [vBasename(path)])), content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(trf('파일 {0}개 · {1}', [plan.files.length, formatSize(plan.totalSize)])), if (plan.unsafe.isNotEmpty) ...[ const SizedBox(height: 8), Text(trf('밖으로 나가는 경로라 풀지 않습니다 ({0}개): {1}', [plan.unsafe.length, plan.unsafe.take(5).map((x) => '${x.$1} (${x.$2})').join(', ')]), style: TextStyle(color: JjColors.danger, fontSize: 12)), ], if (plan.badNames.isNotEmpty) ...[ const SizedBox(height: 6), Text(trf('풀 수 없는 이름 {0}개 (건너뜀): {1}', [plan.badNames.length, plan.badNames.take(5).map((x) => '${x.$1} (${x.$2})').join(', ')]), style: TextStyle(color: JjColors.warning, fontSize: 12)), ], if (plan.links.isNotEmpty) ...[ const SizedBox(height: 6), Text(trf('링크 {0}개는 만들지 않고 건너뜁니다: {1}', [plan.links.length, plan.links.take(5).join(', ')]), style: TextStyle(color: JjColors.warning, fontSize: 12)), ], if (plan.encrypted) ...[ const SizedBox(height: 6), Text(tr('비밀번호가 걸린 파일이 있습니다 (풀 때 묻습니다)'), style: TextStyle(fontSize: 12, color: JjColors.textDim)), ], const SizedBox(height: 10), _placeChoice(ctx, choices, dest, (d) => set(() => dest = d)), Text(vDisplay(dest), maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: JjColors.textDim)), const SizedBox(height: 6), // 좁은 화면 · 큰 글자면 고르기 상자를 다음 줄로 (412 · 글자 1.6배에서 화면 밖으로 나갔다 - 253 시험) Wrap(spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ Text(tr('같은 이름이 있으면'), style: const TextStyle(fontSize: 13)), DropdownButton( value: conflict, items: [ DropdownMenuItem(value: NameConflict.rename, child: Text(tr('이름 바꾸기'))), DropdownMenuItem(value: NameConflict.skip, child: Text(tr('건너뛰기'))), DropdownMenuItem(value: NameConflict.overwrite, child: Text(tr('덮어쓰기'))), ], onChanged: (v) => set(() => conflict = v!), ), ]), // 관리자 판단: 덮어쓴 파일은 휴지통에 남지 않는다 - 되돌릴 수 없다고 알린다 (받는 쪽이 더 새것이면 설정대로 그대로 둠) if (conflict == NameConflict.overwrite) Text( c.settings.copy.skipNewer ? tr('덮어쓴 파일은 되돌릴 수 없습니다 (받는 쪽이 더 새 파일은 그대로 둡니다).') : tr('덮어쓴 파일은 되돌릴 수 없습니다.'), style: TextStyle(color: JjColors.warning, fontSize: 12)), // 관리자 판단: 풀린 크기가 빈 공간보다 크면 풀기 전에 알리고 묻는다 (압축 폭탄 · 공간 부족) if (short) Text(trf('풀린 크기 ({0}) 가 남은 공간 ({1}) 보다 큽니다. 그래도 풀까요?', [formatSize(plan.totalSize), formatSize(f)]), style: TextStyle(color: JjColors.danger)), ]), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), FilledButton( onPressed: plan.files.isEmpty ? null : () => Navigator.pop(ctx, true), child: Text(short ? tr('그래도 풀기') : tr('풀기')), ), ], ); }, ), ); if (go != true || !context.mounted) return null; // 비밀번호가 걸린 파일인데 아직 모르면 묻는다 if (plan.encrypted && (password == null || password.isEmpty)) { password = await askArchivePassword(context, vBasename(path)); if (password == null || !context.mounted) return null; } while (true) { try { final pw = password; final r = await _withProgress(context, trf('압축 푸는 중 · {0}', [vBasename(path)]), (set, cancelled) async { if (kind == ArchiveKind.zip) { return extractZipPlan(local, dest, plan, conflict: conflict, skipNewer: c.settings.copy.skipNewer, password: pw, cancelled: cancelled, onProgress: (d, t) => set(t == 0 ? null : d / t)); } return extract7zPlan(exe!, local, dest, plan, conflict: conflict, skipNewer: c.settings.copy.skipNewer, password: pw, cancelled: cancelled, onProgress: set); }); say([ trf('{0}개를 풀었습니다: {1}', [r.done, vDisplay(dest)]), if (r.skippedExisting.isNotEmpty) trf('같은 이름이라 건너뜀 {0}개', [r.skippedExisting.length]), if (r.renamed.isNotEmpty) trf('이름을 바꿔 풂 {0}개', [r.renamed.length]), if (r.keptNewer.isNotEmpty) trf('{0}개는 더 새 파일이라 그대로 둠', [r.keptNewer.length]), if (plan.unsafe.isNotEmpty) trf('막은 경로 {0}개', [plan.unsafe.length]), if (plan.links.isNotEmpty) trf('건너뛴 링크 {0}개', [plan.links.length]), if (plan.badNames.isNotEmpty) trf('풀 수 없는 이름 {0}개', [plan.badNames.length]), ].join(' · ')); return dest; } on ArchivePasswordException catch (e) { if (!context.mounted) return null; password = await askArchivePassword(context, vBasename(path), wrong: e.wrong); if (password == null || !context.mounted) return null; } on ArchiveCancelled { say(tr('취소했습니다. 풀던 파일은 남지 않습니다 (다 푼 파일은 그대로).')); return null; } catch (e) { say(trf('풀지 못했습니다: {0}', [e])); return null; } } } /// 86: 풀 곳 · 넣을 곳 고르기. 넓으면 칩, 좁으면 (폭 720 미만) 글이 다 보이게 세로 목록 (15 캡처: 412 에서 칩 글이 잘림) Widget _placeChoice(BuildContext context, List<(String, String)> choices, String value, void Function(String) onPick) { if (!isCompact(context)) { return Wrap(spacing: 6, runSpacing: 4, children: [ for (final (d, label) in choices) ChoiceChip(label: Text(label), selected: value == d, onSelected: (_) => onPick(d)), ]); } return RadioGroup( groupValue: value, onChanged: (d) { if (d != null) onPick(d); }, child: Column(mainAxisSize: MainAxisSize.min, children: [ for (final (d, label) in choices) RadioListTile(dense: true, contentPadding: EdgeInsets.zero, value: d, title: Text(label)), ]), ); } /// 86: ZIP 으로 묶기 - 이름 · 압축 정도 (설정으로 기억) · 넣을 곳. 링크 · junction 은 넣지 않는다. 만든 ZIP 경로 (취소 · 실패면 null) Future createZipFlow(BuildContext context, AppController c, List sources, {required String dir, String? otherDir}) async { final messenger = ScaffoldMessenger.maybeOf(context); void say(String t) => messenger?.showSnackBar(SnackBar(content: Text(t))); if (sources.isEmpty) return null; if (sources.any(isDav) || isDav(dir)) { say(tr('WebDAV · SFTP 항목은 ZIP 으로 묶을 수 없습니다. 이 기기의 파일 · 폴더를 골라 주세요.')); return null; } final name = TextEditingController( text: sources.length == 1 ? p.basenameWithoutExtension(sources.first) : p.basename(dir.endsWith(p.separator) ? p.dirname(dir) : dir)); var level = c.settings.tools.zipLevel; var where = dir; // zip_aes: 비밀번호 (비우면 보통 ZIP). 기억하지 않고, 기록에 남기지 않는다 final pass = TextEditingController(); final pass2 = TextEditingController(); var showPass = false; final choices = <(String, String)>[(dir, tr('지금 폴더')), if (otherDir != null && !isDav(otherDir)) (otherDir, tr('다른 창 폴더'))]; String target() { var n = name.text.trim(); if (n.isEmpty) n = 'archive'; if (!n.toLowerCase().endsWith('.zip')) n = '$n.zip'; var t = p.join(where, n); for (var i = 2; File(t).existsSync(); i++) { t = p.join(where, '${p.basenameWithoutExtension(n)} ($i).zip'); } return t; } final go = await showDialog( context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, set) => AlertDialog( scrollable: true, title: Text(trf('ZIP 으로 묶기 · {0}개', [sources.length])), content: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ TextField( controller: name, decoration: InputDecoration(labelText: tr('ZIP 이름'), suffixText: '.zip', isDense: true), onChanged: (_) => set(() {}), ), const SizedBox(height: 10), Wrap(spacing: 6, children: [ for (final (v, label) in [(1, tr('빠르게')), (6, tr('보통')), (9, tr('작게'))]) ChoiceChip(label: Text(label), selected: level == v, onSelected: (_) => set(() => level = v)), ]), const SizedBox(height: 6), _placeChoice(ctx, choices, where, (d) => set(() => where = d)), Text(trf('만들 파일: {0}', [p.basename(target())]), style: TextStyle(fontSize: 12, color: JjColors.textDim)), Text(tr('링크 · junction 은 넣지 않습니다'), style: TextStyle(fontSize: 12, color: JjColors.textDim)), const SizedBox(height: 10), TextField( controller: pass, obscureText: !showPass, autocorrect: false, enableSuggestions: false, decoration: InputDecoration( // 관리자 지시 (10/21, 15 찾음 F1): 칸 이름은 짧게, 괄호 설명은 칸 아래 도움 글로 - 412 폭 en · ja 에서 👁 자리 때문에 이름이 "…" 로 잘렸다 - "선택" 은 아래 도움 글 (비우면 보통 ZIP) 이 알린다 labelText: tr('비밀번호'), helperText: tr('넣으면 AES-256 으로 암호화합니다. 비우면 보통 ZIP'), helperMaxLines: 3, isDense: true, suffixIcon: IconButton( tooltip: showPass ? tr('비밀번호 숨기기') : tr('비밀번호 보기'), icon: Icon(showPass ? Icons.visibility_off_outlined : Icons.visibility_outlined, size: 18), onPressed: () => set(() => showPass = !showPass), ), ), onChanged: (_) => set(() {}), ), if (pass.text.isNotEmpty) ...[ const SizedBox(height: 6), TextField( controller: pass2, obscureText: !showPass, autocorrect: false, enableSuggestions: false, decoration: InputDecoration( labelText: tr('비밀번호 다시 넣기'), isDense: true, errorText: pass2.text.isNotEmpty && pass2.text != pass.text ? tr('두 비밀번호가 다릅니다') : null, ), onChanged: (_) => set(() {}), ), const SizedBox(height: 4), Text(tr('비밀번호를 잊으면 이 ZIP 을 풀 수 없습니다 (앱이 기억하지 않습니다). 파일 이름 목록은 암호화되지 않습니다.'), style: TextStyle(fontSize: 12, color: JjColors.textDim)), // zip_aes: 7-Zip 등 다른 프로그램은 영문 밖 글자 ZIP 비밀번호를 받지 않는다 (10/17 시험) - 막지는 않고 알린다 if (pass.text.codeUnits.any((c) => c > 0x7e)) Text(tr('한글 등 영문 밖 글자가 든 비밀번호는 이 앱에서는 풀리지만 7-Zip 등 다른 프로그램에서는 풀리지 않을 수 있습니다. 다른 기기 · 프로그램과 나눌 ZIP 은 영문 · 숫자 · 기호 비밀번호를 쓰세요.'), style: TextStyle(fontSize: 12, color: JjColors.danger)), ], ]), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), FilledButton( onPressed: pass.text.isNotEmpty && pass.text != pass2.text ? null : () => Navigator.pop(ctx, true), child: Text(pass.text.isEmpty ? tr('묶기') : tr('암호로 묶기')), ), ], ), ), ); final zip = target(); final password = pass.text; name.dispose(); pass.dispose(); pass2.dispose(); if (go != true || !context.mounted) return null; if (level != c.settings.tools.zipLevel) await c.updateSettings((x) => x.tools.zipLevel = level); if (!context.mounted) return null; // zip_aes: 비밀번호 ZIP 은 동봉 7-Zip 으로 (흘려 가며 AES-256 - 큰 동영상도 메모리에 다 올리지 않게) final exe = password.isEmpty ? null : await bundledSevenZip(); if (!context.mounted) return null; if (password.isNotEmpty && exe == null) { say(tr('비밀번호 ZIP 을 만들 7-Zip 이 이 판에 없습니다. 비밀번호 없이 묶거나 앱을 다시 설치해 주세요.')); return null; } try { final (n, links) = await _withProgress( context, password.isEmpty ? trf('ZIP 으로 묶는 중 · {0}', [p.basename(zip)]) : trf('암호 ZIP 으로 묶는 중 · {0}', [p.basename(zip)]), (set, cancelled) => exe == null ? createZip(sources, zip, level: level, cancelled: cancelled, onProgress: (d, t) => set(t == 0 ? null : d / t)) : createZipAes(exe, sources, zip, password: password, level: level, cancelled: cancelled, onProgress: set)); say([trf('{0}개를 묶었습니다: {1}', [n, p.basename(zip)]), if (links > 0) trf('링크 {0}개는 넣지 않음', [links])].join(' · ')); return zip; } on ArchiveCancelled { say(tr('취소했습니다. 만들다 만 ZIP 은 남지 않습니다.')); } catch (e) { say(trf('ZIP 을 만들지 못했습니다: {0}', [e])); } return null; }