From afba0dfede6adaca0a51657a333d16f557de21e8 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:26:12 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=EB=82=98=EB=88=84=EA=B8=B0:=20ai=5Flocal?= =?UTF-8?q?=20(827)=20=EC=9D=84=20=EB=B0=9B=EA=B8=B0=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=EC=86=8C=20=C2=B7=20=EC=97=94=EC=A7=84=20=EC=8B=A4=ED=96=89=20?= =?UTF-8?q?=C2=B7=20=EC=9E=91=EC=97=85=20=EB=AA=A8=EB=93=88=EB=A1=9C=20(?= =?UTF-8?q?=E2=86=92=20482=20=C2=B7=20186=20=C2=B7=20195)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ai_local.dart: AiStore · contentRangeStart · sdDevices · pickAiDevice (설정 읽는 줄 그대로) - ai_local_engine.dart (새): SdDevice · LocalSdEngine · runSdCli · SdRunner · isSdFatalLine · sizeMatches · sdCliLibName · sdExitMessage - ai_jobs.dart (새): AiJobs - 부르는 쪽 import 만 고침 (이름 · 동작 그대로), 약속 시험 2개 (실제 cmd.exe 프로세스 · 시험 폴더) Co-Authored-By: Claude Opus 5.5 --- lib/app/ai_jobs.dart | 195 ++++++++++ lib/app/ai_local.dart | 356 +------------------ lib/app/ai_local_engine.dart | 186 ++++++++++ lib/app/ai_upscale.dart | 1 + lib/platform/android/android_keep_alive.dart | 1 + lib/ui/ai_image_page.dart | 2 + lib/ui/ai_settings.dart | 2 + lib/ui/ai_upscale_ui.dart | 2 + lib/ui/work_panel.dart | 2 +- test/ai_image_test.dart | 2 + test/ai_jobs_contract_test.dart | 61 ++++ test/ai_local_engine_contract_test.dart | 53 +++ test/ai_real_test.dart | 2 +- test/ai_upscale_test.dart | 3 +- test/sd_engine_select_test.dart | 2 +- test/upscale_job_test.dart | 3 +- 16 files changed, 522 insertions(+), 351 deletions(-) create mode 100644 lib/app/ai_jobs.dart create mode 100644 lib/app/ai_local_engine.dart create mode 100644 test/ai_jobs_contract_test.dart create mode 100644 test/ai_local_engine_contract_test.dart diff --git a/lib/app/ai_jobs.dart b/lib/app/ai_jobs.dart new file mode 100644 index 0000000..7054e67 --- /dev/null +++ b/lib/app/ai_jobs.dart @@ -0,0 +1,195 @@ +// 진행 중인 AI 작업 하나 (그리기 · 여러 장 해상도 올리기) 를 맡는 모듈 (ai_local.dart 에서 나눔). +// 약속: +// - 입력: 엔진 [ImageAiEngine] · 요청 [ImageGenRequest] · 저장 폴더 ([AiJobs.run]), 장 수 · 장마다 할 일 ([AiJobs.runTask]). +// - 출력: 만든 그림 목록 (그림마다 옆에 같은 이름의 .json) · 끝낸 장 수. 진행 · 남은 장 · 예상 시간은 +// 필드 ([title] · [progress] · [remaining] · [eta]) 와 [statusLine] 으로, 바뀔 때마다 notifyListeners. +// - 오류: 이미 작업 중이면 [ImageAiException]. 엔진 · 할 일의 오류는 그대로 올리고, 끝나면 늘 [finish] 로 비운다. +// 화면 상태에 기대지 않는다 (작업 알림 · 모니터링 · 화면이 [AiJobs.instance] 를 함께 본다). +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; + +import '../core/ai_catalog.dart'; +import '../core/sd_cli.dart'; +import '../l10n/tr.dart'; +import '../services/image_ai.dart'; +import 'ai_local_engine.dart'; + +/// 123: 진행 중인 AI 작업 (작업 알림 · 모니터링 · 화면이 함께 본다) +class AiJobs extends ChangeNotifier { + static final instance = AiJobs(); + + /// 지금 하는 일 (없으면 null) · 진행 (0~1) · 남은 장 수 · 예상 남은 시간 + String? title; + double? progress; + int remaining = 0; + Duration? eta; + ImageAiEngine? _engine; + void Function()? _cancelTask; + + bool get busy => title != null; + + /// 157: 마지막으로 끝난 일 (작업 현황에 "✓ 4장 저장: 폴더 [열기]") - 다음 일을 시작하면 지운다 + String? lastDone; + String? lastDoneDir; + + /// 176: 끝난 일을 다시 볼 것 (화면을 떠나 끝난 한 장 해상도 올리기 → 비교 · 저장). 작업 현황의 [보기] + Future Function()? lastDoneView; + void Function()? _lastDoneDispose; + + /// [dispose]: 다음 일을 시작하거나 다른 끝난 일로 바뀔 때 (남겨 둔 임시 결과 지우기 등) + void done(String text, {String? dir, Future Function()? view, void Function()? dispose}) { + _clearDone(); + lastDone = text; + lastDoneDir = dir; + lastDoneView = view; + _lastDoneDispose = dispose; + notifyListeners(); + } + + /// [보기] 를 한 번 쓰면 (비교 화면에서 저장 · 버림) 지운다 + void clearDoneView() { + lastDoneView = null; + _lastDoneDispose = null; + notifyListeners(); + } + + void _clearDone() { + _lastDoneDispose?.call(); + _lastDoneDispose = null; + lastDoneView = null; + lastDone = null; + lastDoneDir = null; + } + + void start(String t, ImageAiEngine engine, int count) { + title = t; + _engine = engine; + total = count; + remaining = count; + progress = 0; + eta = null; + _clearDone(); + notifyListeners(); + } + + void update({double? progress, int? remaining, Duration? eta, String? title}) { + this.progress = progress ?? this.progress; + this.remaining = remaining ?? this.remaining; + this.eta = eta ?? this.eta; + this.title = title ?? this.title; + notifyListeners(); + } + + /// 151: 지금 쓰는 받은 파일 (그동안은 [지우기] 를 끈다) + Set inUse = const {}; + + void finish() { + inUse = const {}; + title = null; + progress = null; + total = 0; + remaining = 0; + eta = null; + _engine = null; + notifyListeners(); + } + + void cancel() { + _engine?.cancel(); + _cancelTask?.call(); + } + + /// 121: 여러 장 작업 (폴더 · ZIP 전체 해상도 올리기 등) 을 한 번에 하나로. [body] 는 i 번째 장을 하고 진행 (0~1) 을 알린다. + /// 남은 장 · 예상 시간을 알림 · 작업 현황에 보이고, [onCancel] 로 지금 장을 멈춘다. 끝낸 장 수를 돌려준다. + Future runTask(String title, int count, Future Function(int i, void Function(double? p) progress) body, + {required void Function() onCancel, Set uses = const {}}) async { + if (busy) throw ImageAiException(tr('이미 작업 중입니다')); + inUse = uses; + _clearDone(); + var stop = false; + this.title = title; + total = count; + remaining = count; + progress = 0; + eta = null; + _cancelTask = () { + stop = true; + onCancel(); + }; + notifyListeners(); + final started = DateTime.now(); + var done = 0; + try { + for (var i = 0; i < count && !stop; i++) { + await body(i, (pp) { + final overall = (i + (pp ?? 0)) / count; + final spent = DateTime.now().difference(started); + update( + progress: overall, + remaining: count - i, + eta: overall > 0.02 ? spent * ((1 - overall) / overall) : null, + ); + }); + done++; + } + } finally { + _cancelTask = null; + finish(); + } + return done; + } + + /// 그리기 한 번 (한 번에 하나). 진행 · 남은 장 · 예상 시간을 알리고, 그림마다 옆에 json (프롬프트 · 시드 · 모델) 을 남긴다. + Future> run(ImageAiEngine engine, ImageGenRequest r, {required String outDir}) async { + if (busy) throw ImageAiException(tr('이미 그리는 중입니다')); + final started = DateTime.now(); + final stamp = started.toIso8601String().replaceAll(RegExp(r'[-:T]'), '').split('.').first; + start(trf('AI 그림 ({0})', [engine.label]), engine, r.count); + if (engine is LocalSdEngine) inUse = {...sd15LcmFiles, 'taesd', 'engine-vulkan', 'engine-cuda', 'engine-cudart'}; + try { + final made = await engine.generate(r, outDir: outDir, baseName: 'ai_$stamp', onProgress: (prog, step) { + Duration? left; + // 152: 모델을 읽는 동안 (0.1 까지) 은 남은 시간을 모른다 + if (prog != null && prog > 0.12) { + final spent = DateTime.now().difference(started); + left = spent * ((1 - prog) / prog); + } + // update 는 null 을 무시하므로 직접 지운다 (읽는 동안 지난 "남은 약 0초" 가 남아 있었다) + eta = left; + update( + progress: prog, + title: trf('AI 그림: {0}', [step]), + remaining: prog == null ? null : (r.count - (prog * r.count).floor()).clamp(1, r.count), + eta: left, + ); + }); + for (final g in made) { + try { + await File('${p.withoutExtension(g.path)}.json').writeAsString(r.encode(engine: engine.label, actualSeed: g.seed)); + } catch (_) {} + } + return made; + } finally { + finish(); + } + } + + /// 알림 · 모니터링에 보일 한 줄 + String? get statusLine { + final t = title; + if (t == null) return null; + final left = eta == null ? '' : ' · ${trf('남은 약 {0}', [_dur(eta!)])}'; + // 161: 여러 장이면 "10장 중 3장째" (알림 · 작업 현황 · 그림 화면) + final at = total > 1 ? ' · ${trf('{0}장 중 {1}장째', [total, (total - remaining + 1).clamp(1, total)])}' : ''; + return '$t$at$left'; + } + + /// 161: 이번 작업의 장 수 (남은 장 [remaining] 과 함께 "n장 중 m장째") + int total = 0; + + static String durationText(Duration d) => _dur(d); + + static String _dur(Duration d) => d.inMinutes >= 1 ? trf('{0}분', [d.inMinutes + (d.inSeconds % 60 >= 30 ? 1 : 0)]) : trf('{0}초', [d.inSeconds]); +} diff --git a/lib/app/ai_local.dart b/lib/app/ai_local.dart index 67d5af0..afb86ae 100644 --- a/lib/app/ai_local.dart +++ b/lib/app/ai_local.dart @@ -1,3 +1,11 @@ +// 기기 안 AI 파일 (모델 · 실행 파일) 받기 · 지우기 저장소 [AiStore] 와 처리 장치 고르기 ([sdDevices] · [pickAiDevice]). +// 엔진 실행은 ai_local_engine.dart, 진행 중인 작업은 ai_jobs.dart (이 파일에서 나눔). +// 약속: +// - 입력: 앱 데이터 폴더 · 카탈로그 파일 [AiFile] (받기 · 지우기), 설정 (aiDevice · aiAutoDevice · aiAutoDeviceFor · aiTaesd). +// - 출력: ai/ 아래에 받은 파일 · 표시 파일 (installed/.json, sha256), 진행 (progress · received · totals · queued), +// 쓸 수 있는 처리 장치 목록 · 고른 장치 (auto 면 짧게 재서 고르고 설정에 기억). +// - 오류: 공간 부족 · 손상 (SHA-256 다름) · 취소 · 엔진이 이 기기에서 못 돎 → [ImageAiException] (쉬운 말), +// 연결이 끊기면 몇 번 다시 잇고 그래도 안 되면 failures 에 남기고 [ImageAiException]. import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -6,13 +14,14 @@ import 'package:archive/archive_io.dart'; import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; -import 'package:image/image.dart' as img; import 'package:path/path.dart' as p; import '../core/ai_catalog.dart'; import '../core/sd_cli.dart'; import '../l10n/tr.dart'; import '../services/image_ai.dart'; +import 'ai_jobs.dart'; +import 'ai_local_engine.dart'; import 'app_controller.dart'; import 'component_store.dart' show AccumulatorSink; import 'copy_center.dart' show diskSpace; @@ -407,173 +416,6 @@ int? contentRangeStart(String? header) { return m == null ? null : int.parse(m.group(1)!); } -/// 처리 장치 하나 (실행 파일 + sd-cli --backend 값) -class SdDevice { - final String exe; - final String backend; - final String label; - const SdDevice(this.exe, this.backend, this.label); - String get key => '${p.basename(p.dirname(exe))}:$backend'; -} - -/// 기기 안 엔진 (stable-diffusion.cpp 의 sd-cli 를 따로 띄움 - 메모리가 모자라 꺼져도 앱은 살아 있고, 취소는 프로세스 끝내기) -class LocalSdEngine implements ImageAiEngine { - LocalSdEngine({required this.device, required this.models, this.threads = 0}); - final SdDevice device; - final SdModelPaths models; - final int threads; - Process? _proc; - bool _cancelled = false; - - @override - String get label => trf('이 기기 ({0})', [device.label]); - @override - bool get leavesDevice => false; - - @override - void cancel() { - _cancelled = true; - _proc?.kill(); - } - - @override - Future> generate(ImageGenRequest r, {required String outDir, required String baseName, AiProgress? onProgress}) async { - _cancelled = false; - await Directory(outDir).create(recursive: true); - final seed = resolveSeed(r.seed); - final req = ImageGenRequest( - prompt: r.prompt, - negative: r.negative, - width: r.width, - height: r.height, - steps: r.steps, - cfg: r.cfg, - seed: seed, - count: r.count, - initImage: r.initImage, - strength: r.strength, - model: r.model, - ); - final out = p.join(outDir, '${baseName}_%d.png'); - final args = sdCliArgs(req, models, out: out, backend: device.backend, threads: threads); - final List lines; - try { - lines = await runSdCli(device.exe, args, onProgress: (prog) { - final step = switch (prog.phase) { - // 152: 읽는 동안은 남은 시간을 모른다 - 'load' => tr('모델 읽는 중 …'), - 'sample' => trf('그리는 중 {0}/{1}', [prog.step, prog.steps]), - 'decode' => tr('그림으로 바꾸는 중'), - _ => tr('저장하는 중'), - }; - onProgress?.call(prog.overall, step); - }, onStart: (pr) => _proc = pr); - } catch (_) { - // 150: 취소로 엔진을 끝낸 것은 실패가 아니다 - if (_cancelled) throw ImageAiException(tr('취소했습니다'), cancelled: true); - rethrow; - } - if (_cancelled) throw ImageAiException(tr('취소했습니다'), cancelled: true); - final made = Directory(outDir) - .listSync() - .whereType() - .where((f) => RegExp('^${RegExp.escape(baseName)}_\\d+\\.png\$').hasMatch(p.basename(f.path))) - .toList() - ..sort((a, b) => a.path.compareTo(b.path)); - if (made.isEmpty) throw ImageAiException(trf('그림을 만들지 못했습니다: {0}', [lines.join('\n')])); - // 156-2: 정한 크기가 아니면 (바탕 그림을 그대로 돌려준 것 등) 엔진이 그리지 못한 것 - for (final f in made) { - if (!sizeMatches(f.readAsBytesSync(), r.width, r.height)) { - for (final x in made) { - try { - x.deleteSync(); - } catch (_) {} - } - throw ImageAiException(tr('AI 엔진이 그림을 그리지 못했습니다 (처리 장치를 바꿔 보세요)'), detail: lines.join('\n')); - } - } - return [for (final (i, f) in made.indexed) GeneratedImage(f.path, seed + i)]; - } -} - -/// 그림 [bytes] 가 [w]×[h] 인지 (머리만 읽음) -bool sizeMatches(Uint8List bytes, int w, int h) { - final info = img.findDecoderForData(bytes)?.startDecode(bytes); - return info != null && info.width == w && info.height == h; -} - -/// sd-cli 기록 중 결과를 믿을 수 없게 만드는 실패 (업스케일러 · 장치 · 모델을 만들지 못함) -bool isSdFatalLine(String line) { - final l = line.toLowerCase(); - return const [ - 'new_upscaler_ctx failed', - 'upscale failed', - 'backend config failed', - 'failed to initialize', - 'new_sd_ctx_t failed', - ].any(l.contains); -} - -/// sd-cli 실행기 (시험에서 가짜 엔진으로 바꿀 수 있게) -typedef SdRunner = Future> Function(String exe, List args, - {void Function(SdProgress)? onProgress, void Function(Process)? onStart}); - -/// sd-cli 를 돌리고 진행을 알린다. 실패하면 마지막 출력 몇 줄로 [ImageAiException] -Future> runSdCli(String exe, List args, - {void Function(SdProgress)? onProgress, void Function(Process)? onStart}) async { - final proc = await Process.start(exe, args, workingDirectory: p.dirname(exe)); - onStart?.call(proc); - final tail = []; - final fatal = []; - var saved = false; - void feed(String chunk) { - if (chunk.contains('images saved')) saved = true; - for (final piece in chunk.split(RegExp(r'[\r\n]+'))) { - if (piece.trim().isEmpty) continue; - if (isSdFatalLine(piece)) fatal.add(piece.trim()); - final prog = parseSdProgress(piece); - if (prog != null) { - onProgress?.call(prog); - } else { - tail.add(piece.trim()); - if (tail.length > 8) tail.removeAt(0); - } - } - } - - final a = proc.stdout.transform(const Utf8Decoder(allowMalformed: true)).listen(feed).asFuture(); - final b = proc.stderr.transform(const Utf8Decoder(allowMalformed: true)).listen(feed).asFuture(); - final code = await proc.exitCode; - await Future.wait([a, b]); - // 156-2: 업스케일러 · 장치를 만들지 못해도 sd-cli 는 원본을 저장하고 0 으로 끝난다 - 기록으로 실패를 알아챈다 - if (fatal.isNotEmpty) { - throw ImageAiException(tr('AI 엔진이 해상도를 올리거나 그리지 못했습니다 (처리 장치를 바꿔 보세요)'), detail: fatal.join('\n')); - } - // 그림을 다 저장한 뒤 끝내면서 0 이 아닌 코드로 끝나는 일이 있다 (이 판의 sd-cli) - 저장했으면 성공으로 본다 - if (code != 0 && !saved) { - final err = tail.where((l) => l.contains('[E]') || l.toLowerCase().contains('error')).toList(); - // 150: 쉬운 말 한 줄, 엔진 원문은 [자세히] 에 - throw ImageAiException(sdExitMessage(code), detail: (err.isEmpty ? tail : err).join('\n')); - } - return tail; -} - -/// 195: Android sd-cli 판 고르기 - 모든 코어의 Features 에 asimddp (dotprod) · fphp (fp16) 가 있으면 빠른 v8.2 판, -/// 아니면 (모름 포함) 어느 arm64 에서나 도는 v8.0 판 (tool/build_sdcpp_android.sh) -String sdCliLibName(String cpuinfo) { - final lines = [for (final l in cpuinfo.split('\n')) if (l.trimLeft().toLowerCase().startsWith('features')) l]; - bool has(String line, String f) => line.split(RegExp(r'[\s:]+')).contains(f); - final v82 = lines.isNotEmpty && lines.every((l) => has(l, 'asimddp') && has(l, 'fphp')); - return v82 ? 'libsdcli.so' : 'libsdcli_v80.so'; -} - -/// 195: 엔진이 끝난 코드 → 쉬운 말 (음수 = 그 신호로 죽음). 메모리 · 파일 탓은 실제 그 원인일 때만 말한다 -String sdExitMessage(int code) => switch (code) { - -4 => tr('이 기기의 CPU 가 이 AI 엔진을 지원하지 않습니다'), // SIGILL: CPU 에 없는 명령 - -9 => tr('메모리가 모자라 Android 가 AI 엔진을 끝냈습니다. 다른 앱을 닫거나 환경 설정의 해상도 올리기 메모리를 "적게" 로 해 보세요'), - _ => trf('AI 엔진이 멈췄습니다 (코드 {0}). 엔진 기록은 [자세히] 에 있습니다', [code]), - }; - /// 처리 장치 후보 (Windows: CUDA 판 · Vulkan 판의 GPU · CPU, Android: CPU) Future> sdDevices(AiStore store) async { final out = []; @@ -601,184 +443,6 @@ Future> sdDevices(AiStore store) async { return out; } -/// 123: 진행 중인 AI 작업 (작업 알림 · 모니터링 · 화면이 함께 본다) -class AiJobs extends ChangeNotifier { - static final instance = AiJobs(); - - /// 지금 하는 일 (없으면 null) · 진행 (0~1) · 남은 장 수 · 예상 남은 시간 - String? title; - double? progress; - int remaining = 0; - Duration? eta; - ImageAiEngine? _engine; - void Function()? _cancelTask; - - bool get busy => title != null; - - /// 157: 마지막으로 끝난 일 (작업 현황에 "✓ 4장 저장: 폴더 [열기]") - 다음 일을 시작하면 지운다 - String? lastDone; - String? lastDoneDir; - - /// 176: 끝난 일을 다시 볼 것 (화면을 떠나 끝난 한 장 해상도 올리기 → 비교 · 저장). 작업 현황의 [보기] - Future Function()? lastDoneView; - void Function()? _lastDoneDispose; - - /// [dispose]: 다음 일을 시작하거나 다른 끝난 일로 바뀔 때 (남겨 둔 임시 결과 지우기 등) - void done(String text, {String? dir, Future Function()? view, void Function()? dispose}) { - _clearDone(); - lastDone = text; - lastDoneDir = dir; - lastDoneView = view; - _lastDoneDispose = dispose; - notifyListeners(); - } - - /// [보기] 를 한 번 쓰면 (비교 화면에서 저장 · 버림) 지운다 - void clearDoneView() { - lastDoneView = null; - _lastDoneDispose = null; - notifyListeners(); - } - - void _clearDone() { - _lastDoneDispose?.call(); - _lastDoneDispose = null; - lastDoneView = null; - lastDone = null; - lastDoneDir = null; - } - - void start(String t, ImageAiEngine engine, int count) { - title = t; - _engine = engine; - total = count; - remaining = count; - progress = 0; - eta = null; - _clearDone(); - notifyListeners(); - } - - void update({double? progress, int? remaining, Duration? eta, String? title}) { - this.progress = progress ?? this.progress; - this.remaining = remaining ?? this.remaining; - this.eta = eta ?? this.eta; - this.title = title ?? this.title; - notifyListeners(); - } - - /// 151: 지금 쓰는 받은 파일 (그동안은 [지우기] 를 끈다) - Set inUse = const {}; - - void finish() { - inUse = const {}; - title = null; - progress = null; - total = 0; - remaining = 0; - eta = null; - _engine = null; - notifyListeners(); - } - - void cancel() { - _engine?.cancel(); - _cancelTask?.call(); - } - - /// 121: 여러 장 작업 (폴더 · ZIP 전체 해상도 올리기 등) 을 한 번에 하나로. [body] 는 i 번째 장을 하고 진행 (0~1) 을 알린다. - /// 남은 장 · 예상 시간을 알림 · 작업 현황에 보이고, [onCancel] 로 지금 장을 멈춘다. 끝낸 장 수를 돌려준다. - Future runTask(String title, int count, Future Function(int i, void Function(double? p) progress) body, - {required void Function() onCancel, Set uses = const {}}) async { - if (busy) throw ImageAiException(tr('이미 작업 중입니다')); - inUse = uses; - _clearDone(); - var stop = false; - this.title = title; - total = count; - remaining = count; - progress = 0; - eta = null; - _cancelTask = () { - stop = true; - onCancel(); - }; - notifyListeners(); - final started = DateTime.now(); - var done = 0; - try { - for (var i = 0; i < count && !stop; i++) { - await body(i, (pp) { - final overall = (i + (pp ?? 0)) / count; - final spent = DateTime.now().difference(started); - update( - progress: overall, - remaining: count - i, - eta: overall > 0.02 ? spent * ((1 - overall) / overall) : null, - ); - }); - done++; - } - } finally { - _cancelTask = null; - finish(); - } - return done; - } - - /// 그리기 한 번 (한 번에 하나). 진행 · 남은 장 · 예상 시간을 알리고, 그림마다 옆에 json (프롬프트 · 시드 · 모델) 을 남긴다. - Future> run(ImageAiEngine engine, ImageGenRequest r, {required String outDir}) async { - if (busy) throw ImageAiException(tr('이미 그리는 중입니다')); - final started = DateTime.now(); - final stamp = started.toIso8601String().replaceAll(RegExp(r'[-:T]'), '').split('.').first; - start(trf('AI 그림 ({0})', [engine.label]), engine, r.count); - if (engine is LocalSdEngine) inUse = {...sd15LcmFiles, 'taesd', 'engine-vulkan', 'engine-cuda', 'engine-cudart'}; - try { - final made = await engine.generate(r, outDir: outDir, baseName: 'ai_$stamp', onProgress: (prog, step) { - Duration? left; - // 152: 모델을 읽는 동안 (0.1 까지) 은 남은 시간을 모른다 - if (prog != null && prog > 0.12) { - final spent = DateTime.now().difference(started); - left = spent * ((1 - prog) / prog); - } - // update 는 null 을 무시하므로 직접 지운다 (읽는 동안 지난 "남은 약 0초" 가 남아 있었다) - eta = left; - update( - progress: prog, - title: trf('AI 그림: {0}', [step]), - remaining: prog == null ? null : (r.count - (prog * r.count).floor()).clamp(1, r.count), - eta: left, - ); - }); - for (final g in made) { - try { - await File('${p.withoutExtension(g.path)}.json').writeAsString(r.encode(engine: engine.label, actualSeed: g.seed)); - } catch (_) {} - } - return made; - } finally { - finish(); - } - } - - /// 알림 · 모니터링에 보일 한 줄 - String? get statusLine { - final t = title; - if (t == null) return null; - final left = eta == null ? '' : ' · ${trf('남은 약 {0}', [_dur(eta!)])}'; - // 161: 여러 장이면 "10장 중 3장째" (알림 · 작업 현황 · 그림 화면) - final at = total > 1 ? ' · ${trf('{0}장 중 {1}장째', [total, (total - remaining + 1).clamp(1, total)])}' : ''; - return '$t$at$left'; - } - - /// 161: 이번 작업의 장 수 (남은 장 [remaining] 과 함께 "n장 중 m장째") - int total = 0; - - static String durationText(Duration d) => _dur(d); - - static String _dur(Duration d) => d.inMinutes >= 1 ? trf('{0}분', [d.inMinutes + (d.inSeconds % 60 >= 30 ? 1 : 0)]) : trf('{0}초', [d.inSeconds]); -} - /// 그릴 처리 장치. 'auto' 면 한 번 짧게 재서 (256×256 · 1단계) 가장 빠른 것을 기억한다 (장치 목록이 바뀌면 다시 잰다). Future pickAiDevice(AppController c, AiStore store, {void Function(String step)? onStep}) async { final devices = await sdDevices(store); diff --git a/lib/app/ai_local_engine.dart b/lib/app/ai_local_engine.dart new file mode 100644 index 0000000..54b68d0 --- /dev/null +++ b/lib/app/ai_local_engine.dart @@ -0,0 +1,186 @@ +// 기기 안 AI 엔진 (stable-diffusion.cpp 의 sd-cli) 를 실행하는 모듈 (ai_local.dart 에서 나눔). +// 약속: +// - 입력: sd-cli 실행 파일 경로 · 인자 ([runSdCli]), 처리 장치 [SdDevice] · 모델 경로 [SdModelPaths] ([LocalSdEngine]), +// /proc/cpuinfo 글 ([sdCliLibName]), 끝난 코드 ([sdExitMessage]), 그림 바이트 ([sizeMatches]). +// - 출력: 엔진 기록 마지막 줄들 · 만든 그림 [GeneratedImage] 목록 · 고를 .so 이름 · 쉬운 말 한 줄. +// - 오류: 엔진이 실패하거나 (기록에 치명 줄 · 저장 없이 0 이 아닌 코드) 그림 크기가 다르면 [ImageAiException], +// 취소로 끝내면 cancelled: true 인 [ImageAiException]. +// 화면 · 받기 저장소 (AiStore) 상태에 기대지 않는다. +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:image/image.dart' as img; +import 'package:path/path.dart' as p; + +import '../core/sd_cli.dart'; +import '../l10n/tr.dart'; +import '../services/image_ai.dart'; + +/// 처리 장치 하나 (실행 파일 + sd-cli --backend 값) +class SdDevice { + final String exe; + final String backend; + final String label; + const SdDevice(this.exe, this.backend, this.label); + String get key => '${p.basename(p.dirname(exe))}:$backend'; +} + +/// 기기 안 엔진 (stable-diffusion.cpp 의 sd-cli 를 따로 띄움 - 메모리가 모자라 꺼져도 앱은 살아 있고, 취소는 프로세스 끝내기) +class LocalSdEngine implements ImageAiEngine { + LocalSdEngine({required this.device, required this.models, this.threads = 0}); + final SdDevice device; + final SdModelPaths models; + final int threads; + Process? _proc; + bool _cancelled = false; + + @override + String get label => trf('이 기기 ({0})', [device.label]); + @override + bool get leavesDevice => false; + + @override + void cancel() { + _cancelled = true; + _proc?.kill(); + } + + @override + Future> generate(ImageGenRequest r, {required String outDir, required String baseName, AiProgress? onProgress}) async { + _cancelled = false; + await Directory(outDir).create(recursive: true); + final seed = resolveSeed(r.seed); + final req = ImageGenRequest( + prompt: r.prompt, + negative: r.negative, + width: r.width, + height: r.height, + steps: r.steps, + cfg: r.cfg, + seed: seed, + count: r.count, + initImage: r.initImage, + strength: r.strength, + model: r.model, + ); + final out = p.join(outDir, '${baseName}_%d.png'); + final args = sdCliArgs(req, models, out: out, backend: device.backend, threads: threads); + final List lines; + try { + lines = await runSdCli(device.exe, args, onProgress: (prog) { + final step = switch (prog.phase) { + // 152: 읽는 동안은 남은 시간을 모른다 + 'load' => tr('모델 읽는 중 …'), + 'sample' => trf('그리는 중 {0}/{1}', [prog.step, prog.steps]), + 'decode' => tr('그림으로 바꾸는 중'), + _ => tr('저장하는 중'), + }; + onProgress?.call(prog.overall, step); + }, onStart: (pr) => _proc = pr); + } catch (_) { + // 150: 취소로 엔진을 끝낸 것은 실패가 아니다 + if (_cancelled) throw ImageAiException(tr('취소했습니다'), cancelled: true); + rethrow; + } + if (_cancelled) throw ImageAiException(tr('취소했습니다'), cancelled: true); + final made = Directory(outDir) + .listSync() + .whereType() + .where((f) => RegExp('^${RegExp.escape(baseName)}_\\d+\\.png\$').hasMatch(p.basename(f.path))) + .toList() + ..sort((a, b) => a.path.compareTo(b.path)); + if (made.isEmpty) throw ImageAiException(trf('그림을 만들지 못했습니다: {0}', [lines.join('\n')])); + // 156-2: 정한 크기가 아니면 (바탕 그림을 그대로 돌려준 것 등) 엔진이 그리지 못한 것 + for (final f in made) { + if (!sizeMatches(f.readAsBytesSync(), r.width, r.height)) { + for (final x in made) { + try { + x.deleteSync(); + } catch (_) {} + } + throw ImageAiException(tr('AI 엔진이 그림을 그리지 못했습니다 (처리 장치를 바꿔 보세요)'), detail: lines.join('\n')); + } + } + return [for (final (i, f) in made.indexed) GeneratedImage(f.path, seed + i)]; + } +} + +/// 그림 [bytes] 가 [w]×[h] 인지 (머리만 읽음) +bool sizeMatches(Uint8List bytes, int w, int h) { + final info = img.findDecoderForData(bytes)?.startDecode(bytes); + return info != null && info.width == w && info.height == h; +} + +/// sd-cli 기록 중 결과를 믿을 수 없게 만드는 실패 (업스케일러 · 장치 · 모델을 만들지 못함) +bool isSdFatalLine(String line) { + final l = line.toLowerCase(); + return const [ + 'new_upscaler_ctx failed', + 'upscale failed', + 'backend config failed', + 'failed to initialize', + 'new_sd_ctx_t failed', + ].any(l.contains); +} + +/// sd-cli 실행기 (시험에서 가짜 엔진으로 바꿀 수 있게) +typedef SdRunner = Future> Function(String exe, List args, + {void Function(SdProgress)? onProgress, void Function(Process)? onStart}); + +/// sd-cli 를 돌리고 진행을 알린다. 실패하면 마지막 출력 몇 줄로 [ImageAiException] +Future> runSdCli(String exe, List args, + {void Function(SdProgress)? onProgress, void Function(Process)? onStart}) async { + final proc = await Process.start(exe, args, workingDirectory: p.dirname(exe)); + onStart?.call(proc); + final tail = []; + final fatal = []; + var saved = false; + void feed(String chunk) { + if (chunk.contains('images saved')) saved = true; + for (final piece in chunk.split(RegExp(r'[\r\n]+'))) { + if (piece.trim().isEmpty) continue; + if (isSdFatalLine(piece)) fatal.add(piece.trim()); + final prog = parseSdProgress(piece); + if (prog != null) { + onProgress?.call(prog); + } else { + tail.add(piece.trim()); + if (tail.length > 8) tail.removeAt(0); + } + } + } + + final a = proc.stdout.transform(const Utf8Decoder(allowMalformed: true)).listen(feed).asFuture(); + final b = proc.stderr.transform(const Utf8Decoder(allowMalformed: true)).listen(feed).asFuture(); + final code = await proc.exitCode; + await Future.wait([a, b]); + // 156-2: 업스케일러 · 장치를 만들지 못해도 sd-cli 는 원본을 저장하고 0 으로 끝난다 - 기록으로 실패를 알아챈다 + if (fatal.isNotEmpty) { + throw ImageAiException(tr('AI 엔진이 해상도를 올리거나 그리지 못했습니다 (처리 장치를 바꿔 보세요)'), detail: fatal.join('\n')); + } + // 그림을 다 저장한 뒤 끝내면서 0 이 아닌 코드로 끝나는 일이 있다 (이 판의 sd-cli) - 저장했으면 성공으로 본다 + if (code != 0 && !saved) { + final err = tail.where((l) => l.contains('[E]') || l.toLowerCase().contains('error')).toList(); + // 150: 쉬운 말 한 줄, 엔진 원문은 [자세히] 에 + throw ImageAiException(sdExitMessage(code), detail: (err.isEmpty ? tail : err).join('\n')); + } + return tail; +} + +/// 195: Android sd-cli 판 고르기 - 모든 코어의 Features 에 asimddp (dotprod) · fphp (fp16) 가 있으면 빠른 v8.2 판, +/// 아니면 (모름 포함) 어느 arm64 에서나 도는 v8.0 판 (tool/build_sdcpp_android.sh) +String sdCliLibName(String cpuinfo) { + final lines = [for (final l in cpuinfo.split('\n')) if (l.trimLeft().toLowerCase().startsWith('features')) l]; + bool has(String line, String f) => line.split(RegExp(r'[\s:]+')).contains(f); + final v82 = lines.isNotEmpty && lines.every((l) => has(l, 'asimddp') && has(l, 'fphp')); + return v82 ? 'libsdcli.so' : 'libsdcli_v80.so'; +} + +/// 195: 엔진이 끝난 코드 → 쉬운 말 (음수 = 그 신호로 죽음). 메모리 · 파일 탓은 실제 그 원인일 때만 말한다 +String sdExitMessage(int code) => switch (code) { + -4 => tr('이 기기의 CPU 가 이 AI 엔진을 지원하지 않습니다'), // SIGILL: CPU 에 없는 명령 + -9 => tr('메모리가 모자라 Android 가 AI 엔진을 끝냈습니다. 다른 앱을 닫거나 환경 설정의 해상도 올리기 메모리를 "적게" 로 해 보세요'), + _ => trf('AI 엔진이 멈췄습니다 (코드 {0}). 엔진 기록은 [자세히] 에 있습니다', [code]), + }; diff --git a/lib/app/ai_upscale.dart b/lib/app/ai_upscale.dart index c570b82..91c506c 100644 --- a/lib/app/ai_upscale.dart +++ b/lib/app/ai_upscale.dart @@ -10,6 +10,7 @@ import '../core/sd_cli.dart'; import '../l10n/tr.dart'; import '../services/image_ai.dart'; import 'ai_local.dart'; +import 'ai_local_engine.dart'; /// 121: AI 해상도 올리기 (Real-ESRGAN, stable-diffusion.cpp 의 sd-cli -M upscale). /// 모델은 늘 4배로 그리고, 2 · 3배는 그 결과를 줄여 만든다. 원본은 절대 덮어쓰지 않는다. diff --git a/lib/platform/android/android_keep_alive.dart b/lib/platform/android/android_keep_alive.dart index e14d2ea..27c5020 100644 --- a/lib/platform/android/android_keep_alive.dart +++ b/lib/platform/android/android_keep_alive.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import '../../app/app_controller.dart'; import '../../app/i18n_controller.dart'; +import '../../app/ai_jobs.dart'; import '../../app/ai_local.dart'; import '../../app/download_manager.dart'; import '../../app/copy_center.dart'; diff --git a/lib/ui/ai_image_page.dart b/lib/ui/ai_image_page.dart index b8c7525..e276bf3 100644 --- a/lib/ui/ai_image_page.dart +++ b/lib/ui/ai_image_page.dart @@ -5,7 +5,9 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:path/path.dart' as p; +import '../app/ai_jobs.dart'; import '../app/ai_local.dart'; +import '../app/ai_local_engine.dart'; import '../app/app_controller.dart'; import '../core/reader_sources.dart'; import '../core/sd_cli.dart'; diff --git a/lib/ui/ai_settings.dart b/lib/ui/ai_settings.dart index 0a4a095..b8efea0 100644 --- a/lib/ui/ai_settings.dart +++ b/lib/ui/ai_settings.dart @@ -2,7 +2,9 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import '../app/ai_jobs.dart'; import '../app/ai_local.dart'; +import '../app/ai_local_engine.dart'; import '../app/app_controller.dart'; import '../app/copy_center.dart' show diskSpace; import '../core/ai_catalog.dart'; diff --git a/lib/ui/ai_upscale_ui.dart b/lib/ui/ai_upscale_ui.dart index 60ceaae..2ed72dd 100644 --- a/lib/ui/ai_upscale_ui.dart +++ b/lib/ui/ai_upscale_ui.dart @@ -6,7 +6,9 @@ import 'package:flutter/services.dart' show MethodChannel; import 'package:image/image.dart' as img; import 'package:path/path.dart' as p; +import '../app/ai_jobs.dart'; import '../app/ai_local.dart'; +import '../app/ai_local_engine.dart'; import '../app/ai_upscale.dart'; import '../app/app_controller.dart'; import '../core/ai_catalog.dart'; diff --git a/lib/ui/work_panel.dart b/lib/ui/work_panel.dart index 37d905d..f3d0ee1 100644 --- a/lib/ui/work_panel.dart +++ b/lib/ui/work_panel.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import '../app/app_controller.dart'; -import '../app/ai_local.dart'; +import '../app/ai_jobs.dart'; import '../app/download_manager.dart'; import '../core/models.dart'; import '../services/downloader.dart'; diff --git a/test/ai_image_test.dart b/test/ai_image_test.dart index df33ffd..62a0980 100644 --- a/test/ai_image_test.dart +++ b/test/ai_image_test.dart @@ -5,7 +5,9 @@ import 'package:archive/archive_io.dart'; import 'package:crypto/crypto.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:image/image.dart' as img; +import 'package:jj_mkvmaker/app/ai_jobs.dart'; import 'package:jj_mkvmaker/app/ai_local.dart'; +import 'package:jj_mkvmaker/app/ai_local_engine.dart'; import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/core/ai_catalog.dart'; import 'package:jj_mkvmaker/core/sd_cli.dart'; diff --git a/test/ai_jobs_contract_test.dart b/test/ai_jobs_contract_test.dart new file mode 100644 index 0000000..a2b72ba --- /dev/null +++ b/test/ai_jobs_contract_test.dart @@ -0,0 +1,61 @@ +// ai_jobs.dart 약속 시험: 실제 시험 폴더에 파일을 쓰는 할 일로 runTask 의 진행 · 끝 · 오류 · 취소를 본다. +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/ai_jobs.dart'; +import 'package:jj_mkvmaker/services/image_ai.dart'; +import 'package:path/path.dart' as p; + +void main() { + late Directory dir; + setUp(() => dir = Directory.systemTemp.createTempSync('jj_ai_jobs_')); + tearDown(() => dir.deleteSync(recursive: true)); + + test('runTask: 장마다 할 일 · 진행 · "n장 중 m장째" · 끝나면 비움', () async { + final jobs = AiJobs(); + final lines = []; + final done = await jobs.runTask('일', 3, (i, progress) async { + File(p.join(dir.path, '$i.txt')).writeAsStringSync('$i'); + progress(0.5); + lines.add(jobs.statusLine); + }, onCancel: () {}, uses: {'sd15'}); + expect(done, 3); + expect(dir.listSync().length, 3); + expect(lines.first, contains('3장 중 1장째')); + expect(lines.last, contains('3장 중 3장째')); + expect(jobs.busy, isFalse); + expect(jobs.inUse, isEmpty); + expect(jobs.statusLine, isNull); + }); + + test('runTask: 작업 중에 또 부르면 오류 · 취소하면 그 뒤 장은 하지 않음', () async { + final jobs = AiJobs(); + var cancelled = false; + final run = jobs.runTask('일', 5, (i, _) async { + await expectLater(jobs.runTask('또', 1, (_, _) async {}, onCancel: () {}), throwsA(isA())); + if (i == 1) jobs.cancel(); + }, onCancel: () => cancelled = true); + expect(await run, 2); + expect(cancelled, isTrue); + expect(jobs.busy, isFalse); + }); + + test('runTask: 할 일의 오류는 그대로 · 끝나면 비움', () async { + final jobs = AiJobs(); + await expectLater(jobs.runTask('일', 2, (_, _) async => throw const FileSystemException('x'), onCancel: () {}), + throwsA(isA())); + expect(jobs.busy, isFalse); + }); + + test('done · clearDoneView: 다음 일을 시작하면 남긴 것의 dispose 를 부른다', () async { + final jobs = AiJobs(); + var disposed = 0; + jobs.done('끝', dir: dir.path, view: () async {}, dispose: () => disposed++); + expect((jobs.lastDone, jobs.lastDoneDir, jobs.lastDoneView != null), ('끝', dir.path, true)); + await jobs.runTask('일', 1, (_, _) async {}, onCancel: () {}); + expect(disposed, 1); + expect(jobs.lastDone, isNull); + expect(AiJobs.durationText(const Duration(seconds: 90)), '2분'); + expect(AiJobs.durationText(const Duration(seconds: 20)), '20초'); + }); +} diff --git a/test/ai_local_engine_contract_test.dart b/test/ai_local_engine_contract_test.dart new file mode 100644 index 0000000..3b8c065 --- /dev/null +++ b/test/ai_local_engine_contract_test.dart @@ -0,0 +1,53 @@ +// ai_local_engine.dart 약속 시험: 실제 프로세스 (Windows cmd.exe) 와 실제 PNG 로 입력 · 출력 · 오류를 본다. +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'package:jj_mkvmaker/app/ai_local_engine.dart'; +import 'package:jj_mkvmaker/services/image_ai.dart'; + +void main() { + final cmd = Platform.environment['ComSpec'] ?? r'C:\Windows\System32\cmd.exe'; + + test('runSdCli: 저장했다고 하면 0 이 아닌 코드여도 성공 · 마지막 줄들을 돌려준다', () async { + if (!Platform.isWindows) return markTestSkipped('Windows 전용 (cmd.exe)'); + // "images saved" 줄은 진행 (저장 단계) 으로 읽혀 마지막 줄들에는 들지 않는다 + final tail = await runSdCli(cmd, ['/c', 'echo first&& echo 2 images saved&& exit 1']); + expect(tail, contains('first')); + }); + + test('runSdCli: 저장 없이 0 이 아닌 코드 → 쉬운 말 · 원문은 detail', () async { + if (!Platform.isWindows) return markTestSkipped('Windows 전용 (cmd.exe)'); + await expectLater( + runSdCli(cmd, ['/c', 'echo [E] boom&& exit 3']), + throwsA(isA() + .having((e) => e.message, 'message', sdExitMessage(3)) + .having((e) => e.detail, 'detail', contains('[E] boom')))); + }); + + test('runSdCli: 치명 줄이 있으면 0 으로 끝나도 실패 · onStart 로 프로세스를 넘긴다', () async { + if (!Platform.isWindows) return markTestSkipped('Windows 전용 (cmd.exe)'); + Process? started; + await expectLater(runSdCli(cmd, ['/c', 'echo new_sd_ctx_t failed'], onStart: (p) => started = p), + throwsA(isA().having((e) => e.detail, 'detail', contains('new_sd_ctx_t failed')))); + expect(started, isNotNull); + }); + + test('sizeMatches · isSdFatalLine · sdCliLibName · sdExitMessage', () { + final png = img.encodePng(img.Image(width: 7, height: 5)); + expect(sizeMatches(png, 7, 5), isTrue); + expect(sizeMatches(png, 5, 7), isFalse); + expect(isSdFatalLine('[E] Upscale Failed'), isTrue); + expect(isSdFatalLine('[I] sampling completed'), isFalse); + expect(sdCliLibName('Features : fp asimd fphp asimddp\nFeatures : fp asimd fphp asimddp'), 'libsdcli.so'); + expect(sdCliLibName('Features : fp asimd fphp asimddp\nFeatures : fp asimd'), 'libsdcli_v80.so'); + expect(sdCliLibName(''), 'libsdcli_v80.so'); + expect(sdExitMessage(-4), contains('CPU')); + expect(sdExitMessage(7), contains('7')); + }); + + test('SdDevice.key = 실행 파일 폴더 이름:backend', () { + expect(SdDevice(['', 'ai', 'engine-vulkan', 'sd-cli.exe'].join(Platform.pathSeparator), 'vulkan0', 'GPU').key, + 'engine-vulkan:vulkan0'); + }); +} diff --git a/test/ai_real_test.dart b/test/ai_real_test.dart index 185ee5c..ac3f695 100644 --- a/test/ai_real_test.dart +++ b/test/ai_real_test.dart @@ -1,7 +1,7 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; -import 'package:jj_mkvmaker/app/ai_local.dart'; +import 'package:jj_mkvmaker/app/ai_local_engine.dart'; import 'package:jj_mkvmaker/core/sd_cli.dart'; import 'package:path/path.dart' as p; diff --git a/test/ai_upscale_test.dart b/test/ai_upscale_test.dart index 74a0ca5..a3560e0 100644 --- a/test/ai_upscale_test.dart +++ b/test/ai_upscale_test.dart @@ -2,7 +2,8 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:image/image.dart' as img; -import 'package:jj_mkvmaker/app/ai_local.dart'; +import 'package:jj_mkvmaker/app/ai_jobs.dart'; +import 'package:jj_mkvmaker/app/ai_local_engine.dart'; import 'package:jj_mkvmaker/app/ai_upscale.dart'; import 'package:jj_mkvmaker/app/settings.dart'; import 'package:jj_mkvmaker/core/ai_catalog.dart'; diff --git a/test/sd_engine_select_test.dart b/test/sd_engine_select_test.dart index 8b779e5..a6fc36c 100644 --- a/test/sd_engine_select_test.dart +++ b/test/sd_engine_select_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:jj_mkvmaker/app/ai_local.dart'; +import 'package:jj_mkvmaker/app/ai_local_engine.dart'; /// 195: Android 의 AI 엔진 (sd-cli) 판 고르기 · 엔진이 죽은 신호별 문구 void main() { diff --git a/test/upscale_job_test.dart b/test/upscale_job_test.dart index 36b3818..7d190d6 100644 --- a/test/upscale_job_test.dart +++ b/test/upscale_job_test.dart @@ -4,7 +4,8 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:image/image.dart' as img; -import 'package:jj_mkvmaker/app/ai_local.dart'; +import 'package:jj_mkvmaker/app/ai_jobs.dart'; +import 'package:jj_mkvmaker/app/ai_local_engine.dart'; import 'package:jj_mkvmaker/app/ai_upscale.dart'; import 'package:jj_mkvmaker/app/app_controller.dart'; import 'package:jj_mkvmaker/app/settings.dart'; From cac9a1b67abdfee55f51ad3f2d72418bdb53857a Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:36:22 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=EB=82=98=EB=88=84=EA=B8=B0:=20bookmark=5Fu?= =?UTF-8?q?i=20(783)=20=EC=97=90=EC=84=9C=20=EC=A6=90=EA=B2=A8=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EC=9D=84=20bookmark=5Fmanager=5Fpage=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=EB=A1=9C=20(=E2=86=92=20399=20=C2=B7=20395)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bookmark_ui.dart: 수정 · 새 폴더 · 새 즐겨찾기 창 · askText · 실행 취소 삭제 · 오른쪽 클릭 메뉴 · 끌기 조각 - bookmark_manager_page.dart (새): BookmarkManagerPage (위 조각을 부르기만) - 부르는 쪽 import 만 고침, 약속 시험 1개 (실제 BookmarksController · 시험 폴더) Co-Authored-By: Claude Opus 5.5 --- lib/ui/bookmark_manager_page.dart | 395 ++++++++++++++++++ lib/ui/bookmark_ui.dart | 391 +---------------- lib/ui/browser_page.dart | 1 + test/bookmark_folder_test.dart | 1 + test/bookmark_manager_page_contract_test.dart | 33 ++ 5 files changed, 435 insertions(+), 386 deletions(-) create mode 100644 lib/ui/bookmark_manager_page.dart create mode 100644 test/bookmark_manager_page_contract_test.dart diff --git a/lib/ui/bookmark_manager_page.dart b/lib/ui/bookmark_manager_page.dart new file mode 100644 index 0000000..0627bc7 --- /dev/null +++ b/lib/ui/bookmark_manager_page.dart @@ -0,0 +1,395 @@ +// 즐겨찾기 관리자 화면 (Chrome 의 chrome://bookmarks 참고) - bookmark_ui.dart 에서 나눔. +// 약속: +// - 입력: 즐겨찾기 [BookmarksController] · 열기 콜백 ([onOpen] · [onOpenExternal]) · 지금 페이지 주소 · 제목 · 처음 열 폴더. +// - 출력: 왼쪽 폴더 나무 · 오른쪽 내용 · 검색 결과. 바꾸기 (추가 · 수정 · 삭제 · 옮기기 · 순서) 는 모두 [BookmarksController] 로, +// HTML 내보내기 · 가져오기 · 다른 브라우저에서 가져오기 결과는 알림 한 줄. +// - 오류: 가져올 것이 없거나 파일을 찾지 못하면 알림 글로만 (던지지 않음). 고른 폴더가 없어지면 즐겨찾기 줄로 돌아간다. +// 대화 상자 · 메뉴 · 끌기 조각은 bookmark_ui.dart 의 것을 부른다. +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; + +import '../app/bookmarks_controller.dart'; +import '../core/bookmarks.dart'; +import '../l10n/tr.dart'; +import 'app_actions.dart'; +import 'bookmark_ui.dart'; +import 'theme.dart'; + +class BookmarkManagerPage extends StatefulWidget { + final BookmarksController bm; + final void Function(String url) onOpen; + final void Function(String url)? onOpenExternal; + final String currentUrl; + final String currentTitle; + final String? initialFolderId; + + const BookmarkManagerPage({ + super.key, + required this.bm, + required this.onOpen, + this.onOpenExternal, + this.currentUrl = '', + this.currentTitle = '', + this.initialFolderId, + }); + + @override + State createState() => _BookmarkManagerPageState(); +} + +class _BookmarkManagerPageState extends State { + late String _folder = widget.initialFolderId ?? BookmarkTree.barId; + final _search = TextEditingController(); + final Set _collapsed = {}; + + BookmarksController get bm => widget.bm; + + @override + void dispose() { + _search.dispose(); + super.dispose(); + } + + void _open(String url) { + widget.onOpen(url); + Navigator.maybePop(context); + } + + Future _export() async { + final uri = await FilePicker.saveFile( + dialogTitle: tr('즐겨찾기 내보내기 (HTML)'), + fileName: tr('JJ_MKVMaker_즐겨찾기.html'), + bytes: Uint8List.fromList(utf8.encode(bm.tree.toNetscapeHtml())), + type: FileType.custom, + allowedExtensions: const ['html'], + ); + if (uri == null) return; + final file = uri.scheme == 'file' ? uri.toFilePath() : uri.toString(); + await bm.exportHtml(file); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(trf('내보냈습니다: {0}', [file])))); + } + } + + Future _importHtml() async { + final r = await FilePicker.pickFiles( + dialogTitle: tr('즐겨찾기 HTML 가져오기'), type: FileType.custom, allowedExtensions: const ['html', 'htm']); + final path = r.isEmpty ? null : r.single.path; + if (path == null) return; + final n = await bm.importHtml(path); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(n > 0 ? trf('{0}개를 "기타 즐겨찾기" 에 가져왔습니다.', [n]) : tr('가져올 즐겨찾기가 없습니다.')))); + if (n > 0) setState(() => _folder = BookmarkTree.otherId); + } + + Future _importBrowser(String b) async { + final n = await bm.importFrom(b); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(n < 0 ? tr('이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.') : trf('{0}개를 "기타 즐겨찾기" 에 가져왔습니다.', [n])))); + if (n > 0) setState(() => _folder = BookmarkTree.otherId); + } + + @override + Widget build(BuildContext context) => ListenableBuilder( + listenable: bm, + builder: (context, _) { + if (bm.tree.find(_folder) == null) _folder = BookmarkTree.barId; + final q = _search.text.trim(); + return Scaffold( + body: Column(children: [ + _topBar(), + const Divider(height: 1), + Expanded( + child: Row(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + SizedBox(width: 280, child: _tree()), + const VerticalDivider(width: 1), + Expanded(child: q.isEmpty ? _contents() : _results(q)), + ]), + ), + ]), + ); + }, + ); + + Widget _topBar() => AppTopBar( + nav: const AppNavButtons(), + actions: const AppActions(), + middle: Row(children: [ + Icon(Icons.bookmarks_outlined, color: JjColors.accent), + const SizedBox(width: 8), + if (!isCompact(context)) ...[ + Text(tr('즐겨찾기 관리자'), style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + const SizedBox(width: 24), + ], + Expanded( + child: TextField( + controller: _search, + style: const TextStyle(fontSize: 13), + decoration: InputDecoration( + isDense: true, + filled: true, + fillColor: JjColors.bg, + hintText: tr('즐겨찾기 검색 (이름 · 주소)'), + prefixIcon: const Icon(Icons.search, size: 18), + suffixIcon: _search.text.isEmpty + ? null + : IconButton( + tooltip: tr('검색 지우기'), + icon: const Icon(Icons.close, size: 16), + onPressed: () => setState(_search.clear)), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(18), borderSide: BorderSide.none), + ), + onChanged: (_) => setState(() {}), + ), + ), + const SizedBox(width: 8), + PopupMenuButton( + tooltip: tr('관리 메뉴'), + icon: const Icon(Icons.more_vert), + itemBuilder: (_) => [ + PopupMenuItem(value: 'link', child: Text(tr('새 즐겨찾기 추가'))), + PopupMenuItem(value: 'folder', child: Text(tr('새 폴더 추가'))), + PopupMenuDivider(), + PopupMenuItem(value: 'chrome', child: Text(tr('Chrome 에서 가져오기'))), + PopupMenuItem(value: 'edge', child: Text(tr('Edge 에서 가져오기'))), + PopupMenuItem(value: 'whale', child: Text(tr('Whale 에서 가져오기'))), + PopupMenuItem(value: 'html', child: Text(tr('HTML 파일에서 가져오기'))), + PopupMenuDivider(), + PopupMenuItem(value: 'export', child: Text(tr('HTML 파일로 내보내기'))), + ], + onSelected: (v) async { + switch (v) { + case 'link': + await showNewBookmarkDialog(context, bm, _folder, title: widget.currentTitle, url: widget.currentUrl); + case 'folder': + await showNewFolderDialog(context, bm, _folder); + case 'chrome' || 'edge' || 'whale': + await _importBrowser(v); + case 'html': + await _importHtml(); + case 'export': + await _export(); + } + }, + ), + ]), + ); + + // 왼쪽: 폴더 나무 (누르면 오른쪽에 내용, 즐겨찾기를 끌어다 놓으면 그 폴더로) + Widget _tree() { + final rows = []; + void walk(BookmarkNode f, int depth) { + final subs = f.children!.where((c) => c.isFolder).toList(); + final open = !_collapsed.contains(f.id); + final selected = f.id == _folder; + void folderMenu(Offset at) => showBookmarkMenu(context, + bm: bm, + globalPosition: at, + n: f, + folderId: f.id, + onOpen: _open, + currentUrl: widget.currentUrl, + currentTitle: widget.currentTitle); + final row = BookmarkFolderDrop( + bm: bm, + folderId: f.id, + // 터치 화면: 길게 누르면 오른쪽 클릭과 같은 메뉴 + child: GestureDetector( + onLongPressStart: depth == 0 ? (d) => folderMenu(d.globalPosition) : null, + child: InkWell( + onTap: () => setState(() { + _folder = f.id; + _search.clear(); + }), + onSecondaryTapDown: (d) => folderMenu(d.globalPosition), + child: Container( + color: selected ? JjColors.accent.withValues(alpha: 0.16) : null, + padding: EdgeInsets.only(left: 6 + depth * 16.0, right: 8, top: 6, bottom: 6), + child: Row(children: [ + SizedBox( + width: 22, + child: subs.isEmpty + ? null + : InkWell( + onTap: () => setState(() => open ? _collapsed.add(f.id) : _collapsed.remove(f.id)), + child: Icon(open ? Icons.expand_more : Icons.chevron_right, size: 18), + ), + ), + Icon(depth == 0 ? Icons.folder_special : (selected ? Icons.folder_open : Icons.folder), + size: 18, color: JjColors.amber), + const SizedBox(width: 8), + Expanded(child: Text(f.title, maxLines: 1, overflow: TextOverflow.ellipsis)), + ]), + ), + ), + ), + ); + rows.add(depth == 0 ? row : BookmarkDraggable(n: f, onMenu: folderMenu, child: row)); + if (open) { + for (final s in subs) { + walk(s, depth + 1); + } + } + } + + for (final r in bm.tree.roots) { + walk(r, 0); + } + return Material(color: JjColors.panel, child: ListView(children: rows)); + } + + // 오른쪽: 고른 폴더의 내용 (끌어서 순서 바꾸기, 폴더 위로 끌면 그 안으로, 오른쪽 클릭 메뉴) + Widget _contents() { + final folder = bm.tree.find(_folder)!; + final items = folder.children!; + final path = bm.tree.pathTo(folder.id); + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 12, 6), + child: Row(children: [ + Expanded( + child: Wrap(crossAxisAlignment: WrapCrossAlignment.center, children: [ + for (final (i, f) in path.indexed) ...[ + if (i > 0) Icon(Icons.chevron_right, size: 16, color: JjColors.textDim), + BookmarkFolderDrop( + bm: bm, + folderId: f.id, + child: InkWell( + onTap: () => setState(() => _folder = f.id), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: Text(f.title, + style: TextStyle( + fontSize: 14, + fontWeight: f.id == folder.id ? FontWeight.w600 : FontWeight.normal, + color: f.id == folder.id ? JjColors.text : JjColors.accent)), + ), + ), + ), + ], + ]), + ), + OutlinedButton.icon( + onPressed: () => showNewBookmarkDialog(context, bm, folder.id, + title: widget.currentTitle, url: widget.currentUrl), + icon: const Icon(Icons.add_link, size: 18), + label: Text(tr('즐겨찾기 추가')), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: () => showNewFolderDialog(context, bm, folder.id), + icon: const Icon(Icons.create_new_folder_outlined, size: 18), + label: Text(tr('새 폴더')), + ), + ]), + ), + const Divider(height: 1), + Expanded( + child: items.isEmpty + ? Center( + child: Text(tr('비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.'), + textAlign: TextAlign.center, style: TextStyle(color: JjColors.textDim))) + : ReorderableListView.builder( + buildDefaultDragHandles: false, + itemCount: items.length, + onReorderItem: (o, n) => bm.reorder(folder.id, o, n), + itemBuilder: (_, i) => _row(items[i], i, folder), + ), + ), + Padding( + padding: EdgeInsets.all(8), + child: Text(tr('왼쪽 손잡이로 순서 바꾸기 · 아이콘을 끌어 왼쪽 폴더나 목록의 폴더 위에 놓으면 그 폴더로 이동 · 오른쪽 클릭 메뉴'), + textAlign: TextAlign.center, style: TextStyle(fontSize: 11, color: JjColors.textDim)), + ), + ]); + } + + Widget _row(BookmarkNode n, int i, BookmarkNode folder) { + final tile = ListTile( + dense: true, + leading: Row(mainAxisSize: MainAxisSize.min, children: [ + ReorderableDragStartListener( + index: i, + child: Icon(Icons.drag_indicator, size: 18, color: JjColors.textDim), + ), + const SizedBox(width: 6), + // 아이콘을 끌면 다른 폴더로 옮기기 + BookmarkDraggable( + n: n, + onMenu: (at) => _menu(n, folder, at), + child: Icon(n.isFolder ? Icons.folder : Icons.public, size: 20, color: n.isFolder ? JjColors.amber : JjColors.textDim), + ), + ]), + title: Text(n.title, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text(n.isFolder ? trf('{0}개 · 주소 {1}개', [n.children!.length, bm.tree.countLinks(n)]) : n.url!, + maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11)), + onTap: () => n.isFolder ? setState(() => _folder = n.id) : _open(n.url!), + trailing: IconButton( + tooltip: tr('더 보기'), + icon: const Icon(Icons.more_vert, size: 18), + onPressed: () { + final box = context.findRenderObject() as RenderBox?; + final pos = box == null ? Offset.zero : box.localToGlobal(box.size.topRight(Offset.zero)); + _menu(n, folder, pos); + }, + ), + ); + final row = GestureDetector( + onSecondaryTapDown: (d) => _menu(n, folder, d.globalPosition), + child: n.isFolder ? BookmarkFolderDrop(bm: bm, folderId: n.id, child: tile) : tile, + ); + return KeyedSubtree(key: ValueKey(n.id), child: row); + } + + void _menu(BookmarkNode n, BookmarkNode folder, Offset pos) => showBookmarkMenu(context, + bm: bm, + globalPosition: pos, + n: n, + folderId: folder.id, + onOpen: _open, + onOpenExternal: widget.onOpenExternal, + currentUrl: widget.currentUrl, + currentTitle: widget.currentTitle); + + // 검색 결과 (위치 = 들어 있는 폴더) + Widget _results(String q) { + final found = bm.tree.search(q); + if (found.isEmpty) return Center(child: Text(tr('찾는 즐겨찾기가 없습니다.'), style: TextStyle(color: JjColors.textDim))); + return ListView(children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 6), + child: Text(trf('"{0}" 검색 결과 {1}개', [q, found.length]), style: const TextStyle(fontWeight: FontWeight.w600)), + ), + for (final n in found) + GestureDetector( + onSecondaryTapDown: (d) => _menu(n, bm.tree.parentOf(n.id) ?? bm.tree.bar, d.globalPosition), + child: ListTile( + dense: true, + leading: BookmarkDraggable( + n: n, + onMenu: (at) => _menu(n, bm.tree.parentOf(n.id) ?? bm.tree.bar, at), + child: Icon(n.isFolder ? Icons.folder : Icons.public, size: 20, color: n.isFolder ? JjColors.amber : JjColors.textDim), + ), + title: Text(n.title, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text( + '${bm.tree.pathTo(n.id).reversed.skip(1).toList().reversed.map((f) => f.title).join(' › ')}' + '${n.isFolder ? '' : ' · ${n.url}'}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 11)), + onTap: () => n.isFolder + ? setState(() { + _folder = n.id; + _search.clear(); + }) + : _open(n.url!), + ), + ), + ]); + } +} diff --git a/lib/ui/bookmark_ui.dart b/lib/ui/bookmark_ui.dart index 41cdb6e..8dde1f5 100644 --- a/lib/ui/bookmark_ui.dart +++ b/lib/ui/bookmark_ui.dart @@ -1,21 +1,19 @@ -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import '../app/bookmarks_controller.dart'; import '../core/bookmarks.dart'; -import 'app_actions.dart'; import 'theme.dart'; import '../l10n/tr.dart'; import 'notice.dart'; -// 즐겨찾기 공용 화면 조각 (Chrome 의 즐겨찾기를 참고): +// 즐겨찾기 공용 화면 조각 (Chrome 의 즐겨찾기를 참고). 관리자 화면은 bookmark_manager_page.dart (이 파일에서 나눔). // - 즐겨찾기 수정 창: 이름 · 주소 · 저장할 폴더 (+ 새 폴더) // - 오른쪽 클릭 메뉴: 열기 · 수정 · 삭제 · 이 폴더에 페이지 / 폴더 추가 · 관리자 // - 끌어다 놓기: 즐겨찾기 · 폴더를 폴더 위에 놓으면 그 폴더 안으로 -// - 즐겨찾기 관리자 (전체 화면): 왼쪽 폴더 나무 · 오른쪽 내용 · 검색 · HTML 내보내기 / 가져오기 +// 약속: +// - 입력: [BuildContext] · 즐겨찾기 [BookmarksController] · 대상 [BookmarkNode] / 폴더 id · 열기 콜백. +// - 출력: 바꾸기는 모두 [BookmarksController] 로 (삭제는 "실행 취소" 알림과 함께), 묻기 창은 고른 값 (취소하면 null). +// - 오류: 던지지 않는다. 창이 닫힌 뒤 context 가 없어졌으면 아무것도 하지 않는다. /// 끌고 다니는 즐겨찾기 (id) class BookmarkDrag { @@ -402,382 +400,3 @@ class _BookmarkDraggableState extends State { ); } } - -// ───────── 즐겨찾기 관리자 (Chrome 의 chrome://bookmarks 참고) ───────── - -class BookmarkManagerPage extends StatefulWidget { - final BookmarksController bm; - final void Function(String url) onOpen; - final void Function(String url)? onOpenExternal; - final String currentUrl; - final String currentTitle; - final String? initialFolderId; - - const BookmarkManagerPage({ - super.key, - required this.bm, - required this.onOpen, - this.onOpenExternal, - this.currentUrl = '', - this.currentTitle = '', - this.initialFolderId, - }); - - @override - State createState() => _BookmarkManagerPageState(); -} - -class _BookmarkManagerPageState extends State { - late String _folder = widget.initialFolderId ?? BookmarkTree.barId; - final _search = TextEditingController(); - final Set _collapsed = {}; - - BookmarksController get bm => widget.bm; - - @override - void dispose() { - _search.dispose(); - super.dispose(); - } - - void _open(String url) { - widget.onOpen(url); - Navigator.maybePop(context); - } - - Future _export() async { - final uri = await FilePicker.saveFile( - dialogTitle: tr('즐겨찾기 내보내기 (HTML)'), - fileName: tr('JJ_MKVMaker_즐겨찾기.html'), - bytes: Uint8List.fromList(utf8.encode(bm.tree.toNetscapeHtml())), - type: FileType.custom, - allowedExtensions: const ['html'], - ); - if (uri == null) return; - final file = uri.scheme == 'file' ? uri.toFilePath() : uri.toString(); - await bm.exportHtml(file); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(trf('내보냈습니다: {0}', [file])))); - } - } - - Future _importHtml() async { - final r = await FilePicker.pickFiles( - dialogTitle: tr('즐겨찾기 HTML 가져오기'), type: FileType.custom, allowedExtensions: const ['html', 'htm']); - final path = r.isEmpty ? null : r.single.path; - if (path == null) return; - final n = await bm.importHtml(path); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(n > 0 ? trf('{0}개를 "기타 즐겨찾기" 에 가져왔습니다.', [n]) : tr('가져올 즐겨찾기가 없습니다.')))); - if (n > 0) setState(() => _folder = BookmarkTree.otherId); - } - - Future _importBrowser(String b) async { - final n = await bm.importFrom(b); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text(n < 0 ? tr('이 브라우저의 즐겨찾기 파일을 찾지 못했습니다.') : trf('{0}개를 "기타 즐겨찾기" 에 가져왔습니다.', [n])))); - if (n > 0) setState(() => _folder = BookmarkTree.otherId); - } - - @override - Widget build(BuildContext context) => ListenableBuilder( - listenable: bm, - builder: (context, _) { - if (bm.tree.find(_folder) == null) _folder = BookmarkTree.barId; - final q = _search.text.trim(); - return Scaffold( - body: Column(children: [ - _topBar(), - const Divider(height: 1), - Expanded( - child: Row(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - SizedBox(width: 280, child: _tree()), - const VerticalDivider(width: 1), - Expanded(child: q.isEmpty ? _contents() : _results(q)), - ]), - ), - ]), - ); - }, - ); - - Widget _topBar() => AppTopBar( - nav: const AppNavButtons(), - actions: const AppActions(), - middle: Row(children: [ - Icon(Icons.bookmarks_outlined, color: JjColors.accent), - const SizedBox(width: 8), - if (!isCompact(context)) ...[ - Text(tr('즐겨찾기 관리자'), style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), - const SizedBox(width: 24), - ], - Expanded( - child: TextField( - controller: _search, - style: const TextStyle(fontSize: 13), - decoration: InputDecoration( - isDense: true, - filled: true, - fillColor: JjColors.bg, - hintText: tr('즐겨찾기 검색 (이름 · 주소)'), - prefixIcon: const Icon(Icons.search, size: 18), - suffixIcon: _search.text.isEmpty - ? null - : IconButton( - tooltip: tr('검색 지우기'), - icon: const Icon(Icons.close, size: 16), - onPressed: () => setState(_search.clear)), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(18), borderSide: BorderSide.none), - ), - onChanged: (_) => setState(() {}), - ), - ), - const SizedBox(width: 8), - PopupMenuButton( - tooltip: tr('관리 메뉴'), - icon: const Icon(Icons.more_vert), - itemBuilder: (_) => [ - PopupMenuItem(value: 'link', child: Text(tr('새 즐겨찾기 추가'))), - PopupMenuItem(value: 'folder', child: Text(tr('새 폴더 추가'))), - PopupMenuDivider(), - PopupMenuItem(value: 'chrome', child: Text(tr('Chrome 에서 가져오기'))), - PopupMenuItem(value: 'edge', child: Text(tr('Edge 에서 가져오기'))), - PopupMenuItem(value: 'whale', child: Text(tr('Whale 에서 가져오기'))), - PopupMenuItem(value: 'html', child: Text(tr('HTML 파일에서 가져오기'))), - PopupMenuDivider(), - PopupMenuItem(value: 'export', child: Text(tr('HTML 파일로 내보내기'))), - ], - onSelected: (v) async { - switch (v) { - case 'link': - await showNewBookmarkDialog(context, bm, _folder, title: widget.currentTitle, url: widget.currentUrl); - case 'folder': - await showNewFolderDialog(context, bm, _folder); - case 'chrome' || 'edge' || 'whale': - await _importBrowser(v); - case 'html': - await _importHtml(); - case 'export': - await _export(); - } - }, - ), - ]), - ); - - // 왼쪽: 폴더 나무 (누르면 오른쪽에 내용, 즐겨찾기를 끌어다 놓으면 그 폴더로) - Widget _tree() { - final rows = []; - void walk(BookmarkNode f, int depth) { - final subs = f.children!.where((c) => c.isFolder).toList(); - final open = !_collapsed.contains(f.id); - final selected = f.id == _folder; - void folderMenu(Offset at) => showBookmarkMenu(context, - bm: bm, - globalPosition: at, - n: f, - folderId: f.id, - onOpen: _open, - currentUrl: widget.currentUrl, - currentTitle: widget.currentTitle); - final row = BookmarkFolderDrop( - bm: bm, - folderId: f.id, - // 터치 화면: 길게 누르면 오른쪽 클릭과 같은 메뉴 - child: GestureDetector( - onLongPressStart: depth == 0 ? (d) => folderMenu(d.globalPosition) : null, - child: InkWell( - onTap: () => setState(() { - _folder = f.id; - _search.clear(); - }), - onSecondaryTapDown: (d) => folderMenu(d.globalPosition), - child: Container( - color: selected ? JjColors.accent.withValues(alpha: 0.16) : null, - padding: EdgeInsets.only(left: 6 + depth * 16.0, right: 8, top: 6, bottom: 6), - child: Row(children: [ - SizedBox( - width: 22, - child: subs.isEmpty - ? null - : InkWell( - onTap: () => setState(() => open ? _collapsed.add(f.id) : _collapsed.remove(f.id)), - child: Icon(open ? Icons.expand_more : Icons.chevron_right, size: 18), - ), - ), - Icon(depth == 0 ? Icons.folder_special : (selected ? Icons.folder_open : Icons.folder), - size: 18, color: JjColors.amber), - const SizedBox(width: 8), - Expanded(child: Text(f.title, maxLines: 1, overflow: TextOverflow.ellipsis)), - ]), - ), - ), - ), - ); - rows.add(depth == 0 ? row : BookmarkDraggable(n: f, onMenu: folderMenu, child: row)); - if (open) { - for (final s in subs) { - walk(s, depth + 1); - } - } - } - - for (final r in bm.tree.roots) { - walk(r, 0); - } - return Material(color: JjColors.panel, child: ListView(children: rows)); - } - - // 오른쪽: 고른 폴더의 내용 (끌어서 순서 바꾸기, 폴더 위로 끌면 그 안으로, 오른쪽 클릭 메뉴) - Widget _contents() { - final folder = bm.tree.find(_folder)!; - final items = folder.children!; - final path = bm.tree.pathTo(folder.id); - return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 10, 12, 6), - child: Row(children: [ - Expanded( - child: Wrap(crossAxisAlignment: WrapCrossAlignment.center, children: [ - for (final (i, f) in path.indexed) ...[ - if (i > 0) Icon(Icons.chevron_right, size: 16, color: JjColors.textDim), - BookmarkFolderDrop( - bm: bm, - folderId: f.id, - child: InkWell( - onTap: () => setState(() => _folder = f.id), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), - child: Text(f.title, - style: TextStyle( - fontSize: 14, - fontWeight: f.id == folder.id ? FontWeight.w600 : FontWeight.normal, - color: f.id == folder.id ? JjColors.text : JjColors.accent)), - ), - ), - ), - ], - ]), - ), - OutlinedButton.icon( - onPressed: () => showNewBookmarkDialog(context, bm, folder.id, - title: widget.currentTitle, url: widget.currentUrl), - icon: const Icon(Icons.add_link, size: 18), - label: Text(tr('즐겨찾기 추가')), - ), - const SizedBox(width: 8), - OutlinedButton.icon( - onPressed: () => showNewFolderDialog(context, bm, folder.id), - icon: const Icon(Icons.create_new_folder_outlined, size: 18), - label: Text(tr('새 폴더')), - ), - ]), - ), - const Divider(height: 1), - Expanded( - child: items.isEmpty - ? Center( - child: Text(tr('비어 있는 폴더입니다.\n위의 [즐겨찾기 추가] · [새 폴더] 를 누르거나, 즐겨찾기를 왼쪽 폴더로 끌어다 놓으세요.'), - textAlign: TextAlign.center, style: TextStyle(color: JjColors.textDim))) - : ReorderableListView.builder( - buildDefaultDragHandles: false, - itemCount: items.length, - onReorderItem: (o, n) => bm.reorder(folder.id, o, n), - itemBuilder: (_, i) => _row(items[i], i, folder), - ), - ), - Padding( - padding: EdgeInsets.all(8), - child: Text(tr('왼쪽 손잡이로 순서 바꾸기 · 아이콘을 끌어 왼쪽 폴더나 목록의 폴더 위에 놓으면 그 폴더로 이동 · 오른쪽 클릭 메뉴'), - textAlign: TextAlign.center, style: TextStyle(fontSize: 11, color: JjColors.textDim)), - ), - ]); - } - - Widget _row(BookmarkNode n, int i, BookmarkNode folder) { - final tile = ListTile( - dense: true, - leading: Row(mainAxisSize: MainAxisSize.min, children: [ - ReorderableDragStartListener( - index: i, - child: Icon(Icons.drag_indicator, size: 18, color: JjColors.textDim), - ), - const SizedBox(width: 6), - // 아이콘을 끌면 다른 폴더로 옮기기 - BookmarkDraggable( - n: n, - onMenu: (at) => _menu(n, folder, at), - child: Icon(n.isFolder ? Icons.folder : Icons.public, size: 20, color: n.isFolder ? JjColors.amber : JjColors.textDim), - ), - ]), - title: Text(n.title, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Text(n.isFolder ? trf('{0}개 · 주소 {1}개', [n.children!.length, bm.tree.countLinks(n)]) : n.url!, - maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11)), - onTap: () => n.isFolder ? setState(() => _folder = n.id) : _open(n.url!), - trailing: IconButton( - tooltip: tr('더 보기'), - icon: const Icon(Icons.more_vert, size: 18), - onPressed: () { - final box = context.findRenderObject() as RenderBox?; - final pos = box == null ? Offset.zero : box.localToGlobal(box.size.topRight(Offset.zero)); - _menu(n, folder, pos); - }, - ), - ); - final row = GestureDetector( - onSecondaryTapDown: (d) => _menu(n, folder, d.globalPosition), - child: n.isFolder ? BookmarkFolderDrop(bm: bm, folderId: n.id, child: tile) : tile, - ); - return KeyedSubtree(key: ValueKey(n.id), child: row); - } - - void _menu(BookmarkNode n, BookmarkNode folder, Offset pos) => showBookmarkMenu(context, - bm: bm, - globalPosition: pos, - n: n, - folderId: folder.id, - onOpen: _open, - onOpenExternal: widget.onOpenExternal, - currentUrl: widget.currentUrl, - currentTitle: widget.currentTitle); - - // 검색 결과 (위치 = 들어 있는 폴더) - Widget _results(String q) { - final found = bm.tree.search(q); - if (found.isEmpty) return Center(child: Text(tr('찾는 즐겨찾기가 없습니다.'), style: TextStyle(color: JjColors.textDim))); - return ListView(children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 10, 16, 6), - child: Text(trf('"{0}" 검색 결과 {1}개', [q, found.length]), style: const TextStyle(fontWeight: FontWeight.w600)), - ), - for (final n in found) - GestureDetector( - onSecondaryTapDown: (d) => _menu(n, bm.tree.parentOf(n.id) ?? bm.tree.bar, d.globalPosition), - child: ListTile( - dense: true, - leading: BookmarkDraggable( - n: n, - onMenu: (at) => _menu(n, bm.tree.parentOf(n.id) ?? bm.tree.bar, at), - child: Icon(n.isFolder ? Icons.folder : Icons.public, size: 20, color: n.isFolder ? JjColors.amber : JjColors.textDim), - ), - title: Text(n.title, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Text( - '${bm.tree.pathTo(n.id).reversed.skip(1).toList().reversed.map((f) => f.title).join(' › ')}' - '${n.isFolder ? '' : ' · ${n.url}'}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 11)), - onTap: () => n.isFolder - ? setState(() { - _folder = n.id; - _search.clear(); - }) - : _open(n.url!), - ), - ), - ]); - } -} - diff --git a/lib/ui/browser_page.dart b/lib/ui/browser_page.dart index b69a559..a121ca9 100644 --- a/lib/ui/browser_page.dart +++ b/lib/ui/browser_page.dart @@ -25,6 +25,7 @@ import '../platform/windows/com_guard.dart'; import '../platform/windows/elevation.dart'; import '../services/downloader.dart' show DownloadState; import 'app_actions.dart'; +import 'bookmark_manager_page.dart'; import 'bookmark_ui.dart'; import 'cef_setup.dart' show confirmCefWhenElevated; import 'confirm.dart'; diff --git a/test/bookmark_folder_test.dart b/test/bookmark_folder_test.dart index de08f3a..e39e3eb 100644 --- a/test/bookmark_folder_test.dart +++ b/test/bookmark_folder_test.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:jj_mkvmaker/app/bookmarks_controller.dart'; import 'package:jj_mkvmaker/core/bookmarks.dart'; +import 'package:jj_mkvmaker/ui/bookmark_manager_page.dart'; import 'package:jj_mkvmaker/ui/bookmark_ui.dart'; import 'package:path/path.dart' as p; diff --git a/test/bookmark_manager_page_contract_test.dart b/test/bookmark_manager_page_contract_test.dart new file mode 100644 index 0000000..409694a --- /dev/null +++ b/test/bookmark_manager_page_contract_test.dart @@ -0,0 +1,33 @@ +// bookmark_manager_page.dart 약속 시험: 실제 BookmarksController (시험 폴더의 파일) 로 입력 · 출력 · 없어진 폴더를 본다. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/bookmarks_controller.dart'; +import 'package:jj_mkvmaker/core/bookmarks.dart'; +import 'package:jj_mkvmaker/ui/bookmark_manager_page.dart'; +import 'package:path/path.dart' as p; + +import 'support/temp_dirs.dart'; + +void main() { + deleteTempDirsAtEnd(); + testWidgets('처음 폴더 · 주소 누르면 onOpen · 고른 폴더가 없어지면 즐겨찾기 표시줄로', (tester) async { + tester.view.physicalSize = const Size(1400, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final bm = BookmarksController(p.join(tempDirUntilEnd('jj_bmm_').path, 'bookmarks.json')); + final box = bm.addFolder('보관함', parentId: BookmarkTree.otherId); + bm.addLink('예제', 'https://ex.example', parentId: box.id); + final opened = []; + await tester.pumpWidget(MaterialApp(home: BookmarkManagerPage(bm: bm, onOpen: opened.add, initialFolderId: box.id))); + expect(find.widgetWithText(ListTile, '예제'), findsOneWidget); + + await tester.tap(find.widgetWithText(ListTile, '예제')); + await tester.pumpAndSettle(); + expect(opened, ['https://ex.example']); + + bm.remove(box.id); + await tester.pumpAndSettle(); + expect(find.widgetWithText(ListTile, '예제'), findsNothing); + expect(find.widgetWithText(ListTile, 'YouTube'), findsOneWidget); // 즐겨찾기 표시줄의 기본 내용 + }); +} From f183d5866f571f56cdd4753443c214b8a2e1251d Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:44:51 +0900 Subject: [PATCH 3/4] =?UTF-8?q?=EB=82=98=EB=88=84=EA=B8=B0:=20subtitle=5Fe?= =?UTF-8?q?ditor=5Fpage=20(798)=20=EC=97=90=EC=84=9C=20=EC=98=81=EC=83=81?= =?UTF-8?q?=20=EC=9C=84=20=EC=9E=90=EB=A7=89=20=C2=B7=20=EC=9E=90=EB=A7=89?= =?UTF-8?q?=20=ED=91=9C=20=C2=B7=20=EC=9E=AC=EC=83=9D=20=EB=A7=89=EB=8C=80?= =?UTF-8?q?=EB=A5=BC=20subtitle=5Feditor=5Fpanes=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=EB=A1=9C=20(=E2=86=92=20447=20=C2=B7=20437)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - subtitle_editor_panes.dart (새): SubtitleVideoPane · SubtitleCueTable (+ 머리 줄 · 줄) · SubtitleTransportBar 화면 필드 대신 편집기 · 위치 (ValueListenable) · 재생 상태 · 콜백을 인자로 받는다 - subtitle_editor_page.dart: 재생 · 저장 · 단축키만, 조각은 부르기만 - 약속 시험 1개 (실제 SubtitleEditorController) Co-Authored-By: Claude Opus 5.5 --- lib/ui/subtitle_editor_page.dart | 401 +--------------- lib/ui/subtitle_editor_panes.dart | 437 ++++++++++++++++++ test/subtitle_editor_panes_contract_test.dart | 89 ++++ 3 files changed, 551 insertions(+), 376 deletions(-) create mode 100644 lib/ui/subtitle_editor_panes.dart create mode 100644 test/subtitle_editor_panes_contract_test.dart diff --git a/lib/ui/subtitle_editor_page.dart b/lib/ui/subtitle_editor_page.dart index 787035f..6276b07 100644 --- a/lib/ui/subtitle_editor_page.dart +++ b/lib/ui/subtitle_editor_page.dart @@ -1,3 +1,5 @@ +// 자막 편집 화면: 자막을 불러와 열고 ([openSubtitleEditor]) 재생 · 저장 · 단축키를 맡는다. +// 영상 위 자막 · 자막 표 · 재생 조작 막대는 subtitle_editor_panes.dart 의 조각을 부르기만 한다. import 'dart:async'; import 'package:flutter/material.dart'; @@ -11,8 +13,8 @@ import '../core/srt.dart'; import '../core/text_codec.dart'; import '../services/preview_player.dart'; import 'app_actions.dart'; -import 'key_hint.dart'; import 'path_label.dart' show middleEllipsis; +import 'subtitle_editor_panes.dart'; import 'theme.dart'; import 'timeline.dart'; import '../l10n/tr.dart'; @@ -317,172 +319,31 @@ class _SubtitleEditorPageState extends State { ); } - // ───────── 영상 · 자막 겹쳐 보기 ───────── + // ───────── 화면 조각 (subtitle_editor_panes.dart) ───────── - Widget _videoPane() => Container( - color: Colors.black, - child: Stack( - fit: StackFit.expand, - children: [ - _pl!.buildView(), - // 편집 중인 자막을 영상 위에 표시 - Positioned( - left: 16, - right: 16, - bottom: 20, - child: IgnorePointer( - child: ValueListenableBuilder( - valueListenable: _position, - builder: (_, pos, _) => ListenableBuilder( - listenable: e, - builder: (_, _) { - final text = e.activeAt(pos).map((c) => c.text).join('\n'); - if (text.isEmpty) return const SizedBox(); - return Center( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - color: Colors.black.withValues(alpha: 0.55), - child: Text( - text, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 20, color: Colors.white, height: 1.3), - ), - ), - ); - }, - ), - ), - ), - ), - if (_playerError != null) - Positioned( - left: 8, - right: 8, - top: 8, - child: Container( - padding: const EdgeInsets.all(8), - color: JjColors.danger.withValues(alpha: 0.85), - child: Text(trf('영상 재생 오류: {0}', [_playerError]), - style: const TextStyle(fontSize: 12, color: Colors.white)), - ), - ), - ], - ), + Widget _videoPane() => SubtitleVideoPane(view: _pl!.buildView(), editor: e, position: _position, error: _playerError); + + Widget _cueTable() => SubtitleCueTable(editor: e, active: _active, onSelect: _selectCue); + + Widget _transportBar() => SubtitleTransportBar( + position: _position, + duration: _duration, + playing: _playing, + rate: _rate, + hasSelection: e.selected != null, + onSeekBy: _seekBy, + onPlayPause: () => _pl?.playOrPause(), + onRate: (r) { + setState(() => _rate = r); + _pl?.setRate(r); + }, + onInsert: _insertHere, + onPlaySelected: _playSelected, + onSetStart: _setStartHere, + onSetEnd: _setEndHere, + onAlign: _alignHere, ); - Widget _cueTable() => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const _HeaderRow(), - const Divider(height: 1), - Expanded( - child: ValueListenableBuilder( - valueListenable: _active, - builder: (_, active, _) => ListView.builder( - itemCount: e.cues.length, - itemBuilder: (_, i) { - final c = e.cues[i]; - return _CueRow( - key: ObjectKey(c), - index: i, - cue: c, - editor: e, - orderError: e.invalidOrder.contains(i), - selected: e.selected == c, - active: active == c, - onSelect: () => _selectCue(c), - ); - }, - ), - ), - ), - ], - ); - - // ───────── 재생 조작 막대 ───────── - - Widget _transportBar() { - final hasSel = e.selected != null; - Widget btn(String label, String key, VoidCallback? onTap) => Padding( - padding: const EdgeInsets.only(left: 6), - child: Tooltip( - message: key, - child: OutlinedButton( - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 10), - visualDensity: VisualDensity.compact), - onPressed: onTap, - child: Text(label, style: const TextStyle(fontSize: 12)), - ), - ), - ); - - return Container( - height: 48, - color: JjColors.panel, - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Row( - children: [ - IconButton( - tooltip: tr('5초 뒤로'), - icon: const Icon(Icons.replay_5), - onPressed: () => _seekBy(-5000), - ), - IconButton( - tooltip: keyHint(tr('재생/정지'), 'Ctrl+Space'), - iconSize: 30, - icon: Icon(_playing ? Icons.pause_circle : Icons.play_circle, - color: JjColors.accent), - onPressed: () => _pl?.playOrPause(), - ), - IconButton( - tooltip: tr('5초 앞으로'), - icon: const Icon(Icons.forward_5), - onPressed: () => _seekBy(5000), - ), - const SizedBox(width: 6), - ValueListenableBuilder( - valueListenable: _position, - builder: (_, pos, _) => Text( - '${formatSrtTime(pos)} / ${formatSrtTime(_duration)}', - style: const TextStyle(fontFamily: 'Consolas', fontSize: 13), - ), - ), - const SizedBox(width: 10), - DropdownButton( - value: _rate, - isDense: true, - underline: const SizedBox(), - style: TextStyle(fontSize: 12, color: JjColors.text), - items: [ - for (final r in const [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]) - DropdownMenuItem(value: r, child: Text('${r}x')), - ], - onChanged: (r) { - setState(() => _rate = r!); - _pl?.setRate(r!); - }, - ), - // 폭이 좁으면 (휴대폰) 밀어서 본다. 넓으면 지금처럼 오른쪽에 붙는다 (reverse) - Expanded( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - reverse: true, - child: Row(mainAxisSize: MainAxisSize.min, children: [ - btn(tr('+ 여기에 새 줄'), 'F7', _insertHere), - btn(tr('선택 줄 재생'), 'F8', hasSel ? _playSelected : null), - btn(tr('시작 = 현재'), 'F9', hasSel ? _setStartHere : null), - btn(tr('끝 = 현재'), 'F10', hasSel ? _setEndHere : null), - btn(tr('선택 줄부터 여기로 맞추기'), tr('선택한 줄과 그 뒤 모든 줄을 함께 이동'), hasSel ? _alignHere : null), - ]), - ), - ), - ], - ), - ); - } - Widget _toolbar(BuildContext context) => AppTopBar( nav: const AppNavButtons(), actions: const AppActions(), @@ -584,215 +445,3 @@ class _SubtitleEditorPageState extends State { } } -class _HeaderRow extends StatelessWidget { - const _HeaderRow(); - - @override - Widget build(BuildContext context) => Container( - color: JjColors.panel, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: LayoutBuilder(builder: (context, box) { - if (!_cueNarrow(box.maxWidth)) { - return Row(children: [ - SizedBox(width: 48, child: Text('#', style: _hdr)), - SizedBox(width: 130, child: Text(tr('시작'), style: _hdr)), - SizedBox(width: 130, child: Text(tr('끝'), style: _hdr)), - Expanded(child: Text(tr('내용'), style: _hdr)), - SizedBox(width: 96), - ]); - } - // F9: 좁을 때 - 위 # · 시작 · 끝, 아래 내용 (줄과 같은 두 단) - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - SizedBox(width: _narrowNo, child: Text('#', style: _hdr)), - SizedBox(width: 130, child: Text(tr('시작'), style: _hdr)), - SizedBox(width: 130, child: Text(tr('끝'), style: _hdr)), - ]), - Padding(padding: const EdgeInsets.only(left: _narrowNo), child: Text(tr('내용'), style: _hdr)), - ]); - }), - ); -} - -/// 관리자 지시 (10/21, 15 찾음 F9): 자막 편집 표는 # · 시작 · 끝 · 단추 칸 폭이 정해져 있어 (48 + 130 + 130 + 96) 폰 세로 (412) 에서 -/// 오른쪽으로 넘쳤다 - 내용 칸까지 한 줄에 들지 않으면 두 단 (위: # · 시작 · 끝 · 단추, 아래: 내용). 시간 칸은 줄이지 않는다 (편집이 어려움) -bool _cueNarrow(double width) => width < 48 + 130 + 130 + 96 + 200; - -/// 두 단일 때 # 칸 · 단추 하나의 폭 (위 단이 412 에 들게: 28 + 130 + 130 + 2 × 40 = 368) -const _narrowNo = 28.0; -const _narrowButton = 40.0; - -final _hdr = TextStyle(fontSize: 12, color: JjColors.textDim); - -class _CueRow extends StatefulWidget { - final int index; - final Cue cue; - final SubtitleEditorController editor; - final bool orderError; - final bool selected; - - /// 현재 재생 위치에서 표시 중인 줄 - final bool active; - final VoidCallback? onSelect; - - const _CueRow({ - super.key, - required this.index, - required this.cue, - required this.editor, - required this.orderError, - this.selected = false, - this.active = false, - this.onSelect, - }); - - @override - State<_CueRow> createState() => _CueRowState(); -} - -class _CueRowState extends State<_CueRow> { - late final _start = TextEditingController(text: formatSrtTime(widget.cue.start)); - late final _end = TextEditingController(text: formatSrtTime(widget.cue.end)); - late final _text = TextEditingController(text: widget.cue.text); - final _startFocus = FocusNode(); - final _endFocus = FocusNode(); - bool _startBad = false, _endBad = false; - - @override - void didUpdateWidget(covariant _CueRow old) { - super.didUpdateWidget(old); - // 전체 이동 등 바깥에서 바뀐 시간을 입력칸에 반영 (입력 중이 아닐 때만) - if (!_startFocus.hasFocus && !_startBad) _sync(_start, formatSrtTime(widget.cue.start)); - if (!_endFocus.hasFocus && !_endBad) _sync(_end, formatSrtTime(widget.cue.end)); - } - - void _sync(TextEditingController c, String v) { - if (c.text != v) c.text = v; - } - - @override - void dispose() { - _start.dispose(); - _end.dispose(); - _text.dispose(); - _startFocus.dispose(); - _endFocus.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final e = widget.editor; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - decoration: BoxDecoration( - color: widget.orderError - ? JjColors.danger.withValues(alpha: 0.08) - : widget.selected - ? JjColors.accent.withValues(alpha: 0.12) - : null, - border: Border( - bottom: BorderSide(color: JjColors.border), - left: BorderSide( - color: widget.active ? JjColors.accent : Colors.transparent, width: 3), - ), - ), - child: LayoutBuilder(builder: (context, box) { - final narrow = _cueNarrow(box.maxWidth); - final number = SizedBox( - width: narrow ? _narrowNo : 48, - child: InkWell( - onTap: widget.onSelect, - child: Padding( - padding: const EdgeInsets.only(top: 10, bottom: 10), - child: Text('${widget.index + 1}', - style: TextStyle( - fontSize: 12, - color: widget.selected ? JjColors.accent : JjColors.textDim)), - ), - ), - ); - final start = SizedBox(width: 130, child: _timeField(_start, _startFocus, _startBad, true)); - final end = SizedBox(width: 130, child: _timeField(_end, _endFocus, _endBad, false)); - final text = TextField( - controller: _text, - minLines: 1, - maxLines: 4, - style: const TextStyle(fontSize: 14), - decoration: const InputDecoration(isDense: true, border: InputBorder.none), - onTap: () => e.select(widget.cue), - onChanged: (t) => e.setText(widget.cue, t), - ); - // 좁으면 단추 누를 자리를 40 으로 (기본 48) - 위 단이 폭에 들게 - final small = narrow ? const BoxConstraints(minWidth: _narrowButton, minHeight: _narrowButton) : null; - final buttons = [ - IconButton( - tooltip: tr('아래에 줄 추가'), - iconSize: 18, - constraints: small, - padding: narrow ? EdgeInsets.zero : null, - icon: Icon(Icons.add, color: JjColors.textDim), - onPressed: () => e.insertAfter(widget.index), - ), - IconButton( - tooltip: tr('줄 삭제'), - iconSize: 18, - constraints: small, - padding: narrow ? EdgeInsets.zero : null, - icon: Icon(Icons.delete_outline, color: JjColors.textDim), - onPressed: () => e.removeAt(widget.index), - ), - ]; - if (!narrow) { - return Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - number, - start, - end, - Expanded(child: text), - SizedBox(width: 96, child: Row(children: buttons)), - ]); - } - // F9: 좁을 때 두 단 - 위 # · 시작 · 끝 · 단추, 아래 내용 (# 칸만큼 들여) - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(crossAxisAlignment: CrossAxisAlignment.start, children: [number, start, end, const Spacer(), ...buttons]), - Padding(padding: const EdgeInsets.only(left: _narrowNo), child: text), - ]); - }), - ); - } - - Widget _timeField( - TextEditingController c, FocusNode f, bool bad, bool isStart) { - return Padding( - padding: const EdgeInsets.only(right: 10), - child: TextField( - controller: c, - focusNode: f, - style: const TextStyle(fontSize: 13, fontFamily: 'Consolas'), - decoration: InputDecoration( - isDense: true, - border: InputBorder.none, - errorText: bad ? tr('형식 오류') : null, - errorStyle: const TextStyle(fontSize: 10), - ), - onTap: () => widget.editor.select(widget.cue), - onChanged: (t) { - final ok = widget.editor.setTime(widget.cue, t, isStart: isStart); - setState(() { - if (isStart) { - _startBad = !ok; - } else { - _endBad = !ok; - } - }); - }, - onEditingComplete: () { - // 입력을 마치면 표준 형식으로 정리 - final d = parseSrtTime(c.text); - if (d != null) c.text = formatSrtTime(d); - f.unfocus(); - }, - ), - ); - } -} diff --git a/lib/ui/subtitle_editor_panes.dart b/lib/ui/subtitle_editor_panes.dart new file mode 100644 index 0000000..8ac4d02 --- /dev/null +++ b/lib/ui/subtitle_editor_panes.dart @@ -0,0 +1,437 @@ +// 자막 편집 화면의 조각 (영상 위 자막 · 자막 표 · 재생 조작 막대) - subtitle_editor_page.dart 에서 나눔. +// 약속: +// - 입력: 자막 편집기 [SubtitleEditorController], 재생 위치 · 지금 보이는 줄 ([ValueListenable]), 재생기 화면 · 오류 글, +// 재생 상태 (길이 · 재생 중 · 빠르기 · 고른 줄이 있는지) 와 누를 때 부를 콜백. +// - 출력: 화면 조각. 고치기 (시간 · 내용 · 줄 추가 · 삭제) 는 [SubtitleEditorController] 로, 재생 조작은 콜백으로만. +// - 오류: 던지지 않는다. 시간 형식이 틀리면 그 칸에 "형식 오류" 만 보인다. +// 화면 (State) 의 필드에 기대지 않는다 - 다른 화면에서도 같은 인자로 부를 수 있다. +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../app/subtitle_editor_controller.dart'; +import '../core/srt.dart'; +import '../l10n/tr.dart'; +import 'key_hint.dart'; +import 'theme.dart'; + +/// 영상 위에 편집 중인 자막 (지금 위치의 줄) · 재생 오류를 겹쳐 보인다 +class SubtitleVideoPane extends StatelessWidget { + /// 재생기 화면 (PreviewPlayer.buildView) + final Widget view; + final SubtitleEditorController editor; + final ValueListenable position; + final String? error; + const SubtitleVideoPane({super.key, required this.view, required this.editor, required this.position, this.error}); + + @override + Widget build(BuildContext context) => Container( + color: Colors.black, + child: Stack( + fit: StackFit.expand, + children: [ + view, + // 편집 중인 자막을 영상 위에 표시 + Positioned( + left: 16, + right: 16, + bottom: 20, + child: IgnorePointer( + child: ValueListenableBuilder( + valueListenable: position, + builder: (_, pos, _) => ListenableBuilder( + listenable: editor, + builder: (_, _) { + final text = editor.activeAt(pos).map((c) => c.text).join('\n'); + if (text.isEmpty) return const SizedBox(); + return Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + color: Colors.black.withValues(alpha: 0.55), + child: Text( + text, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 20, color: Colors.white, height: 1.3), + ), + ), + ); + }, + ), + ), + ), + ), + if (error != null) + Positioned( + left: 8, + right: 8, + top: 8, + child: Container( + padding: const EdgeInsets.all(8), + color: JjColors.danger.withValues(alpha: 0.85), + child: Text(trf('영상 재생 오류: {0}', [error]), + style: const TextStyle(fontSize: 12, color: Colors.white)), + ), + ), + ], + ), + ); +} + +/// 자막 표 (머리 줄 + 줄마다 시작 · 끝 · 내용 · 단추). [active] 는 지금 위치에서 보이는 줄 +class SubtitleCueTable extends StatelessWidget { + final SubtitleEditorController editor; + final ValueListenable active; + final void Function(Cue c) onSelect; + const SubtitleCueTable({super.key, required this.editor, required this.active, required this.onSelect}); + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const _HeaderRow(), + const Divider(height: 1), + Expanded( + child: ValueListenableBuilder( + valueListenable: active, + builder: (_, active, _) => ListView.builder( + itemCount: editor.cues.length, + itemBuilder: (_, i) { + final c = editor.cues[i]; + return _CueRow( + key: ObjectKey(c), + index: i, + cue: c, + editor: editor, + orderError: editor.invalidOrder.contains(i), + selected: editor.selected == c, + active: active == c, + onSelect: () => onSelect(c), + ); + }, + ), + ), + ), + ], + ); +} + +/// 재생 조작 막대: 5초 뒤 · 재생 · 5초 앞 · 위치 · 빠르기 · 줄 단추 (F7 ~ F10, 고른 줄이 없으면 줄 단추는 꺼짐) +class SubtitleTransportBar extends StatelessWidget { + final ValueListenable position; + final Duration duration; + final bool playing; + final double rate; + final bool hasSelection; + final void Function(int ms) onSeekBy; + final VoidCallback onPlayPause; + final void Function(double rate) onRate; + final VoidCallback onInsert, onPlaySelected, onSetStart, onSetEnd, onAlign; + const SubtitleTransportBar({ + super.key, + required this.position, + required this.duration, + required this.playing, + required this.rate, + required this.hasSelection, + required this.onSeekBy, + required this.onPlayPause, + required this.onRate, + required this.onInsert, + required this.onPlaySelected, + required this.onSetStart, + required this.onSetEnd, + required this.onAlign, + }); + + @override + Widget build(BuildContext context) { + final hasSel = hasSelection; + Widget btn(String label, String key, VoidCallback? onTap) => Padding( + padding: const EdgeInsets.only(left: 6), + child: Tooltip( + message: key, + child: OutlinedButton( + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 10), + visualDensity: VisualDensity.compact), + onPressed: onTap, + child: Text(label, style: const TextStyle(fontSize: 12)), + ), + ), + ); + + return Container( + height: 48, + color: JjColors.panel, + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + IconButton( + tooltip: tr('5초 뒤로'), + icon: const Icon(Icons.replay_5), + onPressed: () => onSeekBy(-5000), + ), + IconButton( + tooltip: keyHint(tr('재생/정지'), 'Ctrl+Space'), + iconSize: 30, + icon: Icon(playing ? Icons.pause_circle : Icons.play_circle, + color: JjColors.accent), + onPressed: onPlayPause, + ), + IconButton( + tooltip: tr('5초 앞으로'), + icon: const Icon(Icons.forward_5), + onPressed: () => onSeekBy(5000), + ), + const SizedBox(width: 6), + ValueListenableBuilder( + valueListenable: position, + builder: (_, pos, _) => Text( + '${formatSrtTime(pos)} / ${formatSrtTime(duration)}', + style: const TextStyle(fontFamily: 'Consolas', fontSize: 13), + ), + ), + const SizedBox(width: 10), + DropdownButton( + value: rate, + isDense: true, + underline: const SizedBox(), + style: TextStyle(fontSize: 12, color: JjColors.text), + items: [ + for (final r in const [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]) + DropdownMenuItem(value: r, child: Text('${r}x')), + ], + onChanged: (r) => onRate(r!), + ), + // 폭이 좁으면 (휴대폰) 밀어서 본다. 넓으면 지금처럼 오른쪽에 붙는다 (reverse) + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + reverse: true, + child: Row(mainAxisSize: MainAxisSize.min, children: [ + btn(tr('+ 여기에 새 줄'), 'F7', onInsert), + btn(tr('선택 줄 재생'), 'F8', hasSel ? onPlaySelected : null), + btn(tr('시작 = 현재'), 'F9', hasSel ? onSetStart : null), + btn(tr('끝 = 현재'), 'F10', hasSel ? onSetEnd : null), + btn(tr('선택 줄부터 여기로 맞추기'), tr('선택한 줄과 그 뒤 모든 줄을 함께 이동'), hasSel ? onAlign : null), + ]), + ), + ), + ], + ), + ); + } +} + +class _HeaderRow extends StatelessWidget { + const _HeaderRow(); + + @override + Widget build(BuildContext context) => Container( + color: JjColors.panel, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: LayoutBuilder(builder: (context, box) { + if (!_cueNarrow(box.maxWidth)) { + return Row(children: [ + SizedBox(width: 48, child: Text('#', style: _hdr)), + SizedBox(width: 130, child: Text(tr('시작'), style: _hdr)), + SizedBox(width: 130, child: Text(tr('끝'), style: _hdr)), + Expanded(child: Text(tr('내용'), style: _hdr)), + SizedBox(width: 96), + ]); + } + // F9: 좁을 때 - 위 # · 시작 · 끝, 아래 내용 (줄과 같은 두 단) + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + SizedBox(width: _narrowNo, child: Text('#', style: _hdr)), + SizedBox(width: 130, child: Text(tr('시작'), style: _hdr)), + SizedBox(width: 130, child: Text(tr('끝'), style: _hdr)), + ]), + Padding(padding: const EdgeInsets.only(left: _narrowNo), child: Text(tr('내용'), style: _hdr)), + ]); + }), + ); +} + +/// 관리자 지시 (10/21, 15 찾음 F9): 자막 편집 표는 # · 시작 · 끝 · 단추 칸 폭이 정해져 있어 (48 + 130 + 130 + 96) 폰 세로 (412) 에서 +/// 오른쪽으로 넘쳤다 - 내용 칸까지 한 줄에 들지 않으면 두 단 (위: # · 시작 · 끝 · 단추, 아래: 내용). 시간 칸은 줄이지 않는다 (편집이 어려움) +bool _cueNarrow(double width) => width < 48 + 130 + 130 + 96 + 200; + +/// 두 단일 때 # 칸 · 단추 하나의 폭 (위 단이 412 에 들게: 28 + 130 + 130 + 2 × 40 = 368) +const _narrowNo = 28.0; +const _narrowButton = 40.0; + +final _hdr = TextStyle(fontSize: 12, color: JjColors.textDim); + +class _CueRow extends StatefulWidget { + final int index; + final Cue cue; + final SubtitleEditorController editor; + final bool orderError; + final bool selected; + + /// 현재 재생 위치에서 표시 중인 줄 + final bool active; + final VoidCallback? onSelect; + + const _CueRow({ + super.key, + required this.index, + required this.cue, + required this.editor, + required this.orderError, + this.selected = false, + this.active = false, + this.onSelect, + }); + + @override + State<_CueRow> createState() => _CueRowState(); +} + +class _CueRowState extends State<_CueRow> { + late final _start = TextEditingController(text: formatSrtTime(widget.cue.start)); + late final _end = TextEditingController(text: formatSrtTime(widget.cue.end)); + late final _text = TextEditingController(text: widget.cue.text); + final _startFocus = FocusNode(); + final _endFocus = FocusNode(); + bool _startBad = false, _endBad = false; + + @override + void didUpdateWidget(covariant _CueRow old) { + super.didUpdateWidget(old); + // 전체 이동 등 바깥에서 바뀐 시간을 입력칸에 반영 (입력 중이 아닐 때만) + if (!_startFocus.hasFocus && !_startBad) _sync(_start, formatSrtTime(widget.cue.start)); + if (!_endFocus.hasFocus && !_endBad) _sync(_end, formatSrtTime(widget.cue.end)); + } + + void _sync(TextEditingController c, String v) { + if (c.text != v) c.text = v; + } + + @override + void dispose() { + _start.dispose(); + _end.dispose(); + _text.dispose(); + _startFocus.dispose(); + _endFocus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final e = widget.editor; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + decoration: BoxDecoration( + color: widget.orderError + ? JjColors.danger.withValues(alpha: 0.08) + : widget.selected + ? JjColors.accent.withValues(alpha: 0.12) + : null, + border: Border( + bottom: BorderSide(color: JjColors.border), + left: BorderSide( + color: widget.active ? JjColors.accent : Colors.transparent, width: 3), + ), + ), + child: LayoutBuilder(builder: (context, box) { + final narrow = _cueNarrow(box.maxWidth); + final number = SizedBox( + width: narrow ? _narrowNo : 48, + child: InkWell( + onTap: widget.onSelect, + child: Padding( + padding: const EdgeInsets.only(top: 10, bottom: 10), + child: Text('${widget.index + 1}', + style: TextStyle( + fontSize: 12, + color: widget.selected ? JjColors.accent : JjColors.textDim)), + ), + ), + ); + final start = SizedBox(width: 130, child: _timeField(_start, _startFocus, _startBad, true)); + final end = SizedBox(width: 130, child: _timeField(_end, _endFocus, _endBad, false)); + final text = TextField( + controller: _text, + minLines: 1, + maxLines: 4, + style: const TextStyle(fontSize: 14), + decoration: const InputDecoration(isDense: true, border: InputBorder.none), + onTap: () => e.select(widget.cue), + onChanged: (t) => e.setText(widget.cue, t), + ); + // 좁으면 단추 누를 자리를 40 으로 (기본 48) - 위 단이 폭에 들게 + final small = narrow ? const BoxConstraints(minWidth: _narrowButton, minHeight: _narrowButton) : null; + final buttons = [ + IconButton( + tooltip: tr('아래에 줄 추가'), + iconSize: 18, + constraints: small, + padding: narrow ? EdgeInsets.zero : null, + icon: Icon(Icons.add, color: JjColors.textDim), + onPressed: () => e.insertAfter(widget.index), + ), + IconButton( + tooltip: tr('줄 삭제'), + iconSize: 18, + constraints: small, + padding: narrow ? EdgeInsets.zero : null, + icon: Icon(Icons.delete_outline, color: JjColors.textDim), + onPressed: () => e.removeAt(widget.index), + ), + ]; + if (!narrow) { + return Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + number, + start, + end, + Expanded(child: text), + SizedBox(width: 96, child: Row(children: buttons)), + ]); + } + // F9: 좁을 때 두 단 - 위 # · 시작 · 끝 · 단추, 아래 내용 (# 칸만큼 들여) + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(crossAxisAlignment: CrossAxisAlignment.start, children: [number, start, end, const Spacer(), ...buttons]), + Padding(padding: const EdgeInsets.only(left: _narrowNo), child: text), + ]); + }), + ); + } + + Widget _timeField( + TextEditingController c, FocusNode f, bool bad, bool isStart) { + return Padding( + padding: const EdgeInsets.only(right: 10), + child: TextField( + controller: c, + focusNode: f, + style: const TextStyle(fontSize: 13, fontFamily: 'Consolas'), + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + errorText: bad ? tr('형식 오류') : null, + errorStyle: const TextStyle(fontSize: 10), + ), + onTap: () => widget.editor.select(widget.cue), + onChanged: (t) { + final ok = widget.editor.setTime(widget.cue, t, isStart: isStart); + setState(() { + if (isStart) { + _startBad = !ok; + } else { + _endBad = !ok; + } + }); + }, + onEditingComplete: () { + // 입력을 마치면 표준 형식으로 정리 + final d = parseSrtTime(c.text); + if (d != null) c.text = formatSrtTime(d); + f.unfocus(); + }, + ), + ); + } +} diff --git a/test/subtitle_editor_panes_contract_test.dart b/test/subtitle_editor_panes_contract_test.dart new file mode 100644 index 0000000..f5f9081 --- /dev/null +++ b/test/subtitle_editor_panes_contract_test.dart @@ -0,0 +1,89 @@ +// subtitle_editor_panes.dart 약속 시험: 실제 SubtitleEditorController 로 조각의 입력 · 출력 (편집기 · 콜백) · 오류 표시를 본다. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/subtitle_editor_controller.dart'; +import 'package:jj_mkvmaker/core/srt.dart'; +import 'package:jj_mkvmaker/ui/subtitle_editor_panes.dart'; + +SubtitleEditorController _editor() => SubtitleEditorController([ + Cue(const Duration(seconds: 1), const Duration(seconds: 2), '첫 줄'), + Cue(const Duration(seconds: 3), const Duration(seconds: 4), '둘째 줄'), + ], saveCharset: 'UTF-8'); + +Widget _host(Widget child) => MaterialApp(home: Scaffold(body: child)); + +void main() { + testWidgets('SubtitleCueTable: 번호를 누르면 onSelect · 시간 고치기 · 틀린 형식 · 줄 추가 · 삭제는 편집기로', (tester) async { + tester.view.physicalSize = const Size(1200, 700); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final e = _editor(); + final active = ValueNotifier(null); + final picked = []; + await tester.pumpWidget(_host(ListenableBuilder( + listenable: e, builder: (_, _) => SubtitleCueTable(editor: e, active: active, onSelect: picked.add)))); + await tester.tap(find.text('2')); + expect(picked, [e.cues[1]]); + + await tester.enterText(find.text('00:00:01,000'), '00:00:01,500'); + expect(e.cues[0].start, const Duration(milliseconds: 1500)); + await tester.enterText(find.text('00:00:03,000'), '말도 안 됨'); + await tester.pump(); + expect(find.text('형식 오류'), findsOneWidget); + expect(e.cues[1].start, const Duration(seconds: 3)); + + await tester.tap(find.byTooltip('아래에 줄 추가').first); + await tester.pump(); + expect(e.cues.length, 3); + await tester.tap(find.byTooltip('줄 삭제').last); + await tester.pump(); + expect(e.cues.length, 2); + }); + + testWidgets('SubtitleTransportBar: 고른 줄이 없으면 줄 단추 꺼짐 · 누르면 콜백', (tester) async { + // 줄 단추가 모두 보이는 넓은 화면 (좁으면 밀어서 본다) + tester.view.physicalSize = const Size(1800, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final calls = []; + Widget bar(bool sel) => _host(SubtitleTransportBar( + position: ValueNotifier(const Duration(seconds: 5)), + duration: const Duration(minutes: 1), + playing: false, + rate: 1.0, + hasSelection: sel, + onSeekBy: (ms) => calls.add('seek $ms'), + onPlayPause: () => calls.add('play'), + onRate: (r) => calls.add('rate $r'), + onInsert: () => calls.add('insert'), + onPlaySelected: () => calls.add('playSel'), + onSetStart: () => calls.add('start'), + onSetEnd: () => calls.add('end'), + onAlign: () => calls.add('align'), + )); + await tester.pumpWidget(bar(false)); + expect(find.text('00:00:05,000 / 00:01:00,000'), findsOneWidget); + expect(tester.widget(find.widgetWithText(OutlinedButton, '시작 = 현재')).onPressed, isNull); + await tester.tap(find.byIcon(Icons.replay_5)); + await tester.tap(find.byIcon(Icons.play_circle)); + await tester.tap(find.text('+ 여기에 새 줄')); + await tester.pumpWidget(bar(true)); + await tester.tap(find.text('끝 = 현재')); + await tester.tap(find.text('1.0x')); + await tester.pumpAndSettle(); + await tester.tap(find.text('2.0x').last); + await tester.pumpAndSettle(); + expect(calls, ['seek -5000', 'play', 'insert', 'end', 'rate 2.0']); + }); + + testWidgets('SubtitleVideoPane: 지금 위치의 줄 · 재생 오류를 겹쳐 보인다', (tester) async { + final e = _editor(); + final pos = ValueNotifier(const Duration(milliseconds: 1200)); + await tester.pumpWidget(_host(SubtitleVideoPane(view: const SizedBox(), editor: e, position: pos, error: '열 수 없음'))); + expect(find.text('첫 줄'), findsOneWidget); + expect(find.textContaining('열 수 없음'), findsOneWidget); + pos.value = const Duration(milliseconds: 2500); + await tester.pump(); + expect(find.text('첫 줄'), findsNothing); + }); +} From c75317e947239f3ce4df6910f0757c066a2d7e98 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:11:43 +0900 Subject: [PATCH 4/4] =?UTF-8?q?=EB=82=98=EB=88=84=EA=B8=B0:=20MainActivity?= =?UTF-8?q?.kt=20(758)=20=EC=9D=98=20=EC=B1=84=EB=84=90=EC=9D=84=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=EB=B3=84=20=EC=B2=98=EB=A6=AC=EA=B8=B0=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=EB=A1=9C=20(=E2=86=92=2086=20+=205=EA=B0=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChannelModule.kt (31): 처리기 약속 (handle → 맡으면 true) · inBackground - AppChannel.kt (193): secret · 뒤로 보내기 · 백그라운드 · 끝내기 · take… · 업데이트 설치 · 아이콘 · 작업 알림 - StorageChannel.kt (233): 전체 접근 · 저장소 목록 · 남은 공간 · 미디어 다시 읽기 · 주소 · 폴더 · 다른 앱으로 열기 · 공유 - SystemChannel.kt (221): Tor 웹 뷰 · Shizuku · 앱 관리자 · 망 종류 · 다운로드 프로그램 - OpenedFiles.kt (105): 받은 파일 읽기 · LaunchInbox - MainActivity 는 엔진 · 처리기 등록만. 채널 이름 · 메서드 이름 · 인자 그대로, flutter build apk --debug 통과 Co-Authored-By: Claude Opus 5.5 --- .../kotlin/com/jj/jj_mkvmaker/AppChannel.kt | 196 +++++ .../com/jj/jj_mkvmaker/ChannelModule.kt | 31 + .../kotlin/com/jj/jj_mkvmaker/MainActivity.kt | 700 +----------------- .../kotlin/com/jj/jj_mkvmaker/OpenedFiles.kt | 106 +++ .../com/jj/jj_mkvmaker/StorageChannel.kt | 235 ++++++ .../com/jj/jj_mkvmaker/SystemChannel.kt | 223 ++++++ 6 files changed, 807 insertions(+), 684 deletions(-) create mode 100644 android/app/src/main/kotlin/com/jj/jj_mkvmaker/AppChannel.kt create mode 100644 android/app/src/main/kotlin/com/jj/jj_mkvmaker/ChannelModule.kt create mode 100644 android/app/src/main/kotlin/com/jj/jj_mkvmaker/OpenedFiles.kt create mode 100644 android/app/src/main/kotlin/com/jj/jj_mkvmaker/StorageChannel.kt create mode 100644 android/app/src/main/kotlin/com/jj/jj_mkvmaker/SystemChannel.kt diff --git a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/AppChannel.kt b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/AppChannel.kt new file mode 100644 index 0000000..b214ce8 --- /dev/null +++ b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/AppChannel.kt @@ -0,0 +1,196 @@ +package com.jj.jj_mkvmaker + +import android.Manifest +import android.app.Activity +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.provider.Settings +import io.flutter.embedding.engine.FlutterEngineCache +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.io.File + +/// 앱 자체 (비밀 보관 · 뒤로 보내기 · 백그라운드 · 끝내기 · 받은 파일 가져가기 · 업데이트 설치 · 아이콘 · 작업 알림) 채널 처리기. +/// 약속: +/// - 입력: 메서드 secretReadAll · secretWrite · secretDelete · moveToBack · setBackground · exitApp · takeOpenedFiles · takeShowJobs · +/// installApk · canInstallPackages · uninstallSelf · setAppIcon · keepAlive · notificationTexts · notifyDone · stopKeepAlive +/// 와 그 인자 (나누기 전과 같음). 켜질 때 받은 것은 [inbox] 에서 꺼낸다. +/// - 출력: 메서드마다 같은 답 (값 · null). 맡지 않은 메서드면 false. +/// - 오류: 비밀 보관 실패 "secret", 설치 허용이 꺼져 있으면 "PERMISSION", 그 밖의 설치 실패 "INSTALL". +class AppChannel(private val activity: Activity, private val inbox: LaunchInbox) : ChannelModule { + override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean { + with(activity) { + when (call.method) { + "secretReadAll" -> { + try { + result.success(SecretBox.readAll(applicationContext)) + } catch (e: Exception) { + result.error("secret", e.toString(), null) + } + } + "secretWrite" -> { + try { + SecretBox.write(applicationContext, call.argument("key")!!, call.argument("value")!!) + result.success(null) + } catch (e: Exception) { + result.error("secret", e.toString(), null) + } + } + "secretDelete" -> { + SecretBox.delete(applicationContext, call.argument("key")!!) + result.success(null) + } + "moveToBack" -> { + // 작업 중에 ← : 앱을 끝내지 않고 뒤로 보낸다 (작업 계속) + moveTaskToBack(true) + result.success(null) + } + "setBackground" -> { + MainActivity.background = call.argument("on") ?: false + result.success(null) + } + "exitApp" -> { + // [종료]: 작업 알림 · 살려 둔 엔진까지 모두 끝낸다 + result.success(null) + MainActivity.background = false + stopService(Intent(applicationContext, KeepAliveService::class.java)) + FlutterEngineCache.getInstance().remove(MainActivity.ENGINE_ID) + finishAndRemoveTask() + android.os.Handler(mainLooper).postDelayed({ + android.os.Process.killProcess(android.os.Process.myPid()) + }, 300) + } + "takeOpenedFiles" -> { + result.success(inbox.openFiles) + inbox.openFiles = emptyList() + } + "takeShowJobs" -> { + result.success(inbox.showJobs) + inbox.showJobs = false + } + "installApk" -> try { + installApk(call.argument("path") ?: "") + result.success(null) + } catch (e: InstallPermissionNeededException) { + // 설치 허용 설정 화면을 열었다: 앱이 받은 파일로 다시 설치하게 따로 알린다 + result.error("PERMISSION", e.message ?: e.toString(), null) + } catch (e: Exception) { + // 170: 다른 오류를 "설치 허용 필요" 로 오해하지 않게 (예전에는 IllegalStateException 이면 모두 허용 필요로 보았다) + result.error("INSTALL", "${e.javaClass.simpleName}: ${e.message ?: ""}", null) + } + // 170: 지금 이 앱이 다른 앱 (APK) 을 설치할 수 있는지 + "canInstallPackages" -> result.success( + Build.VERSION.SDK_INT < Build.VERSION_CODES.O || packageManager.canRequestPackageInstalls() + ) + // 예전 버전으로 되돌리기: Android 는 낮은 버전을 위에 설치하지 못해 앱을 지우는 확인 창을 연다 + "uninstallSelf" -> { + startActivity(Intent(Intent.ACTION_DELETE, Uri.parse("package:$packageName")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + result.success(null) + } + "setAppIcon" -> result.success(setAppIcon(call.argument("id") ?: "")) + "keepAlive" -> { + keepAlive(call.argument("text") ?: "", call.argument("progress") ?: -1, + call.argument("icon") ?: "") + result.success(null) + } + "notificationTexts" -> { + // 57: 알림 글 · 채널 이름 (앱 화면 언어) + val m = call.arguments as? Map<*, *> ?: emptyMap() + KeepAliveService.setTexts(applicationContext, + m.entries.mapNotNull { (k, v) -> if (k is String && v is String) k to v else null }.toMap()) + result.success(null) + } + "notifyDone" -> { + KeepAliveService.notifyDone(applicationContext, call.argument("title") ?: "", + call.argument("text") ?: "") + result.success(null) + } + "stopKeepAlive" -> { + stopService(Intent(applicationContext, KeepAliveService::class.java)) + result.success(null) + } + else -> return false + } + } + return true + } + + /// 받은 업데이트 APK 로 Android 설치 화면을 연다. 앱 전용 폴더의 파일이라 FileProvider 로 넘긴다. + /// 처음이면 "이 출처의 앱 설치 허용" 설정 화면을 먼저 연다 (허용하고 돌아와 [설치 계속] - 받은 파일을 그대로 쓴다) + private fun Activity.installApk(path: String) { + val src = File(path) + if (!src.isFile) throw IllegalArgumentException("APK 파일이 없습니다: $path") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !packageManager.canRequestPackageInstalls()) { + startActivity( + Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName")) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + throw InstallPermissionNeededException("\"이 출처의 앱 설치 허용\" 을 켠 뒤 다시 [업데이트] 를 누르세요") + } + // FileProvider 가 내보내는 폴더 (cache/updates) 로 옮긴다 + val dir = File(cacheDir, "updates").apply { mkdirs() } + val apk = File(dir, "update.apk") + if (src.absolutePath != apk.absolutePath) { + apk.delete() + if (!src.renameTo(apk)) { + src.copyTo(apk, overwrite = true) + src.delete() + } + } + val uri = androidx.core.content.FileProvider.getUriForFile(this, "$packageName.files", apk) + startActivity( + Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, "application/vnd.android.package-archive") + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } + + /// 170: 설치 허용이 꺼져 있어 설정 화면을 연 경우만 (다른 오류와 구별) + class InstallPermissionNeededException(message: String) : Exception(message) + + /// 앱 아이콘 바꾸기: 고른 아이콘의 activity-alias 만 켜고 나머지는 끈다 (앱은 계속 켜 둔 채로) + private fun Activity.setAppIcon(id: String): Boolean { + val ids = listOf("yellow", "black", "film_jj", "film", "blue") + if (id !in ids) return false + val pm = packageManager + for (i in ids) { + val name = "$packageName.Icon" + i.split('_').joinToString("") { it.replaceFirstChar(Char::uppercaseChar) } + val state = if (i == id) PackageManager.COMPONENT_ENABLED_STATE_ENABLED + else PackageManager.COMPONENT_ENABLED_STATE_DISABLED + val cn = android.content.ComponentName(this, name) + if (pm.getComponentEnabledSetting(cn) != state) { + pm.setComponentEnabledSetting(cn, state, PackageManager.DONT_KILL_APP) + } + } + return true + } + + /// 작업 진행 알림 (포그라운드 서비스) 시작 · 갱신. 처음 한 번 알림 권한을 묻는다 (Android 13+, 거절해도 작업은 계속) + private var askedNotifications = false + + private fun Activity.keepAlive(text: String, progress: Int, icon: String) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !askedNotifications && !isDestroyed && + checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + askedNotifications = true + requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 2) + } + if (KeepAliveService.running) { + KeepAliveService.update(applicationContext, text, progress, icon) + return + } + val i = Intent(applicationContext, KeepAliveService::class.java) + .putExtra(KeepAliveService.EXTRA_TEXT, text) + .putExtra(KeepAliveService.EXTRA_PROGRESS, progress) + .putExtra(KeepAliveService.EXTRA_ICON, icon) + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) applicationContext.startForegroundService(i) + else applicationContext.startService(i) + } catch (e: Exception) { + // 백그라운드에서는 새로 시작할 수 없다 (Android 12+). 이미 떠 있으면 다음 갱신 때 바뀐다 + android.util.Log.w("jj_mkvmaker", "keepAlive", e) + } + } +} diff --git a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/ChannelModule.kt b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/ChannelModule.kt new file mode 100644 index 0000000..df30536 --- /dev/null +++ b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/ChannelModule.kt @@ -0,0 +1,31 @@ +package com.jj.jj_mkvmaker + +import android.os.Handler +import android.os.Looper +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +/// "jj_mkvmaker/android" 채널을 기능마다 나눈 처리기 (MainActivity 는 등록만 한다). +/// 약속: +/// - 입력: Dart 의 메서드 호출 (메서드 이름 · 인자 - 이름 · 인자는 나누기 전과 같다). +/// - 출력: 맡은 메서드면 [result] 에 답하고 true, 아니면 아무것도 하지 않고 false (다음 처리기로). +/// - 오류: 메서드마다 정한 대로 result.error 또는 실패 값 (false · null) 으로 답한다 (던지지 않음). +interface ChannelModule { + fun handle(call: MethodCall, result: MethodChannel.Result): Boolean +} + +/// 오래 걸리는 일 (처음 켤 때 실행 파일 풀기 · yt-dlp 업데이트 · 앱 목록 등) 은 메인 스레드 밖에서 하고, 답은 메인 스레드에서. +/// 실패하면 오류 코드 "TOOLS" (예외 이름: 맨 안쪽 원인) +fun inBackground(result: MethodChannel.Result, work: () -> Any?) { + val main = Handler(Looper.getMainLooper()) + Thread { + try { + val value = work() + main.post { result.success(value) } + } catch (e: Throwable) { + android.util.Log.e("jj_mkvmaker", "download tools", e) + val cause = generateSequence(e) { it.cause }.last() + main.post { result.error("TOOLS", "${e.javaClass.simpleName}: ${cause.message ?: cause}", null) } + } + }.start() +} diff --git a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt index fac88ac..380a60b 100644 --- a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt +++ b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/MainActivity.kt @@ -1,32 +1,21 @@ package com.jj.jj_mkvmaker -import android.Manifest import android.content.Context import android.content.Intent -import android.content.pm.PackageManager -import android.media.MediaScannerConnection -import android.net.Uri -import android.os.Build -import android.os.Environment -import android.provider.Settings -import androidx.webkit.ProfileStore -import androidx.webkit.WebViewFeature -import com.yausername.aria2c.Aria2c -import com.yausername.ffmpeg.FFmpeg -import com.yausername.youtubedl_android.YoutubeDL import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngineCache import io.flutter.plugin.common.MethodChannel -import java.io.File -/// 저장소 전체 접근 (동영상 옆 jj_mkv 폴더에 MKV 를 만들기 위해) 확인 · 요청, 다운로드 프로그램 준비 +/// 저장소 전체 접근 (동영상 옆 jj_mkv 폴더에 MKV 를 만들기 위해) 확인 · 요청, 다운로드 프로그램 준비. +/// "jj_mkvmaker/android" 채널은 기능마다 나눈 처리기 ([AppChannel] · [StorageChannel] · [SystemChannel]) 를 등록만 한다. class MainActivity : FlutterActivity() { companion object { /// 작업 알림을 눌렀을 때: 작업 현황 화면을 연다 const val ACTION_SHOW_JOBS = "com.jj.jj_mkvmaker.SHOW_JOBS" - private const val ENGINE_ID = "main" + /// 살려 두는 엔진의 이름 ([AppChannel] 의 exitApp 도 지운다) + internal const val ENGINE_ID = "main" /// 백그라운드로 실행 (환경 설정, Dart 가 알려 준다): 켜져 있으면 화면을 닫아도 (← · 최근 앱에서 밀기) /// Dart 엔진을 없애지 않아 동기화 · MKV 만들기 · 다운로드가 계속된다 (작업 알림 서비스가 프로세스를 살려 둠). @@ -63,233 +52,31 @@ class MainActivity : FlutterActivity() { private var channel: MethodChannel? = null - /// 앱이 켜질 때 작업 알림으로 열렸는지 (Dart 가 준비되면 가져간다) - private var pendingShowJobs = false - - /// 다른 앱에서 연 동영상 (앱이 켜질 때 받은 것: Dart 가 준비되면 가져간다) - private var pendingOpen: List> = emptyList() + /// 앱이 켜질 때 받은 것 (작업 알림으로 열렸는지 · 다른 앱에서 연 동영상: Dart 가 준비되면 가져간다) + private val inbox = LaunchInbox() + private val opened by lazy { OpenedFiles(this) } override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) - pendingOpen = openedFiles(intent) - pendingShowJobs = intent?.action == ACTION_SHOW_JOBS + inbox.openFiles = opened.of(intent) + inbox.showJobs = intent?.action == ACTION_SHOW_JOBS val ch = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "jj_mkvmaker/android") channel = ch // 88: SMB (smbj) - 탐색기의 SMB 서버 탭 SmbBridge.attach(flutterEngine.dartExecutor.binaryMessenger) if (reusedEngine) { // Dart 는 이미 돌고 있어 take… 로 가져가지 않는다: 바로 보낸다 - if (pendingShowJobs) ch.invokeMethod("showJobs", null) - if (pendingOpen.isNotEmpty()) ch.invokeMethod("openFiles", pendingOpen) - pendingShowJobs = false - pendingOpen = emptyList() + if (inbox.showJobs) ch.invokeMethod("showJobs", null) + if (inbox.openFiles.isNotEmpty()) ch.invokeMethod("openFiles", inbox.openFiles) + inbox.showJobs = false + inbox.openFiles = emptyList() } + val modules = listOf(AppChannel(this, inbox), StorageChannel(this), SystemChannel(this)) ch.setMethodCallHandler { call, result -> - when (call.method) { - "secretReadAll" -> { - try { - result.success(SecretBox.readAll(applicationContext)) - } catch (e: Exception) { - result.error("secret", e.toString(), null) - } - } - "secretWrite" -> { - try { - SecretBox.write(applicationContext, call.argument("key")!!, call.argument("value")!!) - result.success(null) - } catch (e: Exception) { - result.error("secret", e.toString(), null) - } - } - "secretDelete" -> { - SecretBox.delete(applicationContext, call.argument("key")!!) - result.success(null) - } - "moveToBack" -> { - // 작업 중에 ← : 앱을 끝내지 않고 뒤로 보낸다 (작업 계속) - moveTaskToBack(true) - result.success(null) - } - "setBackground" -> { - background = call.argument("on") ?: false - result.success(null) - } - "exitApp" -> { - // [종료]: 작업 알림 · 살려 둔 엔진까지 모두 끝낸다 - result.success(null) - background = false - stopService(Intent(applicationContext, KeepAliveService::class.java)) - FlutterEngineCache.getInstance().remove(ENGINE_ID) - finishAndRemoveTask() - android.os.Handler(mainLooper).postDelayed({ - android.os.Process.killProcess(android.os.Process.myPid()) - }, 300) - } - "takeOpenedFiles" -> { - result.success(pendingOpen) - pendingOpen = emptyList() - } - "takeShowJobs" -> { - result.success(pendingShowJobs) - pendingShowJobs = false - } - "hasAllFilesAccess" -> result.success(hasAllFilesAccess()) - "requestAllFilesAccess" -> { - requestAllFilesAccess() - result.success(null) - } - "storageRoot" -> result.success(Environment.getExternalStorageDirectory().path) - // 앱에 들어 있는 rsync (librsync.so) 가 풀리는 곳 - "nativeLibDir" -> result.success(applicationInfo.nativeLibraryDir) - // Tor 화면: 이 기기 웹 뷰가 프록시 걸기 · 따로 프로필 · 페이지보다 먼저 스크립트를 지원하는지 (없으면 화면을 열지 않음) - "torWebSupport" -> result.success(mapOf( - "proxyOverride" to WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE), - "multiProfile" to WebViewFeature.isFeatureSupported(WebViewFeature.MULTI_PROFILE), - "documentStartScript" to WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT), - )) - // Tor 화면 프로필 (쿠키 · 저장소 · 캐시) 지우기. 웹 뷰가 아직 쓰고 있으면 false (나중에 다시) - "torProfileClear" -> result.success(clearWebProfile(call.argument("name") ?: "jj_tor")) - "storageVolumes" -> result.success(storageVolumes()) - "openUrl" -> result.success(openUrl(call.argument("url") ?: "")) - "installApk" -> try { - installApk(call.argument("path") ?: "") - result.success(null) - } catch (e: InstallPermissionNeededException) { - // 설치 허용 설정 화면을 열었다: 앱이 받은 파일로 다시 설치하게 따로 알린다 - result.error("PERMISSION", e.message ?: e.toString(), null) - } catch (e: Exception) { - // 170: 다른 오류를 "설치 허용 필요" 로 오해하지 않게 (예전에는 IllegalStateException 이면 모두 허용 필요로 보았다) - result.error("INSTALL", "${e.javaClass.simpleName}: ${e.message ?: ""}", null) - } - // 170: 지금 이 앱이 다른 앱 (APK) 을 설치할 수 있는지 - "canInstallPackages" -> result.success( - Build.VERSION.SDK_INT < Build.VERSION_CODES.O || packageManager.canRequestPackageInstalls() - ) - "openFolder" -> result.success(openFolder(call.argument("path") ?: "")) - // X-plore 비교 5-2 시스템 폴더 보기 (Shizuku, 읽기 전용 - ShizukuBridge 의 고정 명령 셋뿐) - "shizukuStatus" -> result.success(ShizukuBridge.status()) - "shizukuRequest" -> shizukuRequest(result) - "shizukuList" -> inBackground(result) { ShizukuBridge.list(call.argument("path") ?: "") } - "shizukuStat" -> inBackground(result) { ShizukuBridge.stat(call.argument("path") ?: "") } - "shizukuCopy" -> inBackground(result) { - ShizukuBridge.copyTo(call.argument("path") ?: "", call.argument("dest") ?: "") - } - // X-plore 비교 5-2 앱 관리자 (사용자 결정 10/21, 관리자 판단: 모든 앱 목록 권한 없이 런처에 보이는 앱만) - "launcherApps" -> inBackground(result) { launcherApps() } - "appIcon" -> inBackground(result) { appIcon(call.argument("package") ?: "", call.argument("size") ?: 96) } - "appDetails" -> result.success(try { - startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:" + (call.argument("package") ?: ""))) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) - true - } catch (e: Exception) { - false - }) - // 지우기는 시스템의 지우기 창 (사용자가 거기서 확인) - "uninstallApp" -> result.success(try { - startActivity(Intent(Intent.ACTION_DELETE, Uri.parse("package:" + (call.argument("package") ?: ""))) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) - true - } catch (e: Exception) { - false - }) - // X-plore 맞추기 (관리자 지시 10/20): .nomedia 를 넣고 지운 뒤 갤러리 · 음악 앱이 다시 읽게 (실패해도 조용히) - "mediaScan" -> result.success(try { - val paths = call.argument>("paths") ?: emptyList() - if (paths.isNotEmpty()) MediaScannerConnection.scanFile(applicationContext, paths.toTypedArray(), null, null) - true - } catch (e: Exception) { - false - }) - // 예전 버전으로 되돌리기: Android 는 낮은 버전을 위에 설치하지 못해 앱을 지우는 확인 창을 연다 - "uninstallSelf" -> { - startActivity(Intent(Intent.ACTION_DELETE, Uri.parse("package:$packageName")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) - result.success(null) - } - // 28 APK 점검 · 관리자 판단 (10/19): 주소마다 망 종류 - Wi-Fi 공유 · SMB 찾기가 Wi-Fi · 유선만 쓰게 (모바일 데이터 · VPN 은 뺀다) - "netKinds" -> result.success(netKinds()) - // X-plore 비교 5-1 ② (관리자 지시 10/21): 지금 쓰는 망이 데이터 요금제인지 (모바일 데이터 · 요금제로 정한 Wi-Fi). - // VPN (Tailscale 등) 은 그 아래 망을 따른다 - 집 Wi-Fi 위의 VPN 은 요금제 아님, 모바일 데이터 위의 VPN 은 요금제 - "activeMetered" -> result.success(try { - (getSystemService(Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager).isActiveNetworkMetered - } catch (e: Exception) { - true - }) - "diskSpace" -> result.success( - try { - val st = android.os.StatFs(call.argument("path") ?: "") - listOf(st.availableBytes, st.totalBytes) - } catch (e: Exception) { - null - } - ) - "openWith" -> result.success( - openWith(call.argument("path") ?: "", call.argument("choose") ?: false) - ) - "shareFiles" -> result.success(shareFiles(call.argument>("paths") ?: emptyList())) - "setAppIcon" -> result.success(setAppIcon(call.argument("id") ?: "")) - "keepAlive" -> { - keepAlive(call.argument("text") ?: "", call.argument("progress") ?: -1, - call.argument("icon") ?: "") - result.success(null) - } - "notificationTexts" -> { - // 57: 알림 글 · 채널 이름 (앱 화면 언어) - val m = call.arguments as? Map<*, *> ?: emptyMap() - KeepAliveService.setTexts(applicationContext, - m.entries.mapNotNull { (k, v) -> if (k is String && v is String) k to v else null }.toMap()) - result.success(null) - } - "notifyDone" -> { - KeepAliveService.notifyDone(applicationContext, call.argument("title") ?: "", - call.argument("text") ?: "") - result.success(null) - } - "stopKeepAlive" -> { - stopService(Intent(applicationContext, KeepAliveService::class.java)) - result.success(null) - } - "downloadToolsInit" -> inBackground(result) { downloadToolsInit() } - "downloadToolsUpdate" -> inBackground(result) { - YoutubeDL.updateYoutubeDL(applicationContext)?.name ?: "" - } - else -> result.notImplemented() - } + if (modules.none { it.handle(call, result) }) result.notImplemented() } } - /// 받은 업데이트 APK 로 Android 설치 화면을 연다. 앱 전용 폴더의 파일이라 FileProvider 로 넘긴다. - /// 처음이면 "이 출처의 앱 설치 허용" 설정 화면을 먼저 연다 (허용하고 돌아와 [설치 계속] - 받은 파일을 그대로 쓴다) - private fun installApk(path: String) { - val src = File(path) - if (!src.isFile) throw IllegalArgumentException("APK 파일이 없습니다: $path") - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !packageManager.canRequestPackageInstalls()) { - startActivity( - Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:$packageName")) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - ) - throw InstallPermissionNeededException("\"이 출처의 앱 설치 허용\" 을 켠 뒤 다시 [업데이트] 를 누르세요") - } - // FileProvider 가 내보내는 폴더 (cache/updates) 로 옮긴다 - val dir = File(cacheDir, "updates").apply { mkdirs() } - val apk = File(dir, "update.apk") - if (src.absolutePath != apk.absolutePath) { - apk.delete() - if (!src.renameTo(apk)) { - src.copyTo(apk, overwrite = true) - src.delete() - } - } - val uri = androidx.core.content.FileProvider.getUriForFile(this, "$packageName.files", apk) - startActivity( - Intent(Intent.ACTION_VIEW) - .setDataAndType(uri, "application/vnd.android.package-archive") - .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) - ) - } - - /// 170: 설치 허용이 꺼져 있어 설정 화면을 연 경우만 (다른 오류와 구별) - class InstallPermissionNeededException(message: String) : Exception(message) - /// 앱이 켜져 있을 때 다른 앱에서 동영상을 열면 (launchMode singleTop) override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) @@ -297,462 +84,7 @@ class MainActivity : FlutterActivity() { channel?.invokeMethod("showJobs", null) return } - val files = openedFiles(intent) + val files = opened.of(intent) if (files.isNotEmpty()) channel?.invokeMethod("openFiles", files) } - - /// 연결 프로그램 (VIEW) · 공유 (SEND) 로 받은 동영상: [{path (실제 경로, 모르면 null), uri, name}] - private fun openedFiles(intent: Intent?): List> { - if (intent == null) return emptyList() - val uris = mutableListOf() - when (intent.action) { - Intent.ACTION_VIEW -> intent.data?.let { uris.add(it) } - Intent.ACTION_SEND -> intentStream(intent)?.let { uris.add(it) } - Intent.ACTION_SEND_MULTIPLE -> intentStreams(intent)?.let { uris.addAll(it) } - } - return uris.map { mapOf("path" to realPath(it), "uri" to it.toString(), "name" to displayName(it)) } - } - - @Suppress("DEPRECATION") - private fun intentStream(i: Intent): Uri? = - if (Build.VERSION.SDK_INT >= 33) i.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) - else i.getParcelableExtra(Intent.EXTRA_STREAM) - - @Suppress("DEPRECATION") - private fun intentStreams(i: Intent): List? = - if (Build.VERSION.SDK_INT >= 33) i.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java) - else i.getParcelableArrayListExtra(Intent.EXTRA_STREAM) - - private fun displayName(uri: Uri): String? = try { - contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use { - if (it.moveToFirst()) it.getString(0) else null - } - } catch (e: Exception) { - null - } ?: uri.lastPathSegment?.substringAfterLast('/') - - /// content:// → 실제 파일 경로 ("모든 파일에 대한 접근" 이 있으면 그 경로로 읽고 옆에 MKV 를 만들 수 있다). 모르면 null. - private fun realPath(uri: Uri): String? { - fun ok(path: String?) = path != null && File(path).isFile - if (uri.scheme == "file") return uri.path?.takeIf { ok(it) } - if (uri.scheme != "content") return null - // 1. 저장소 문서 (primary:Download/a.mp4 · XXXX-XXXX:Movies/a.mp4) - try { - if (android.provider.DocumentsContract.isDocumentUri(this, uri) && - uri.authority == "com.android.externalstorage.documents" - ) { - val id = android.provider.DocumentsContract.getDocumentId(uri) - val vol = id.substringBefore(':') - val rel = id.substringAfter(':', "") - val base = if (vol == "primary") Environment.getExternalStorageDirectory().path else "/storage/$vol" - val path = "$base/$rel" - if (ok(path)) return path - } - } catch (e: Exception) { - } - // 2. 미디어 저장소 등이 알려 주는 경로 (_data) - try { - contentResolver.query(uri, arrayOf("_data"), null, null, null)?.use { - if (it.moveToFirst()) { - val path = it.getString(0) - if (ok(path)) return path - } - } - } catch (e: Exception) { - } - // 3. 주소 안의 경로 조각을 저장소 맨 위에 붙여 본다 (파일 앱마다 주소 모양이 달라서) - // 예: content://…/device_storage/0/Download/a.mp4 → /storage/emulated/0/Download/a.mp4 (크기가 같을 때만) - val size = try { - contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.SIZE), null, null, null)?.use { - if (it.moveToFirst() && !it.isNull(0)) it.getLong(0) else -1L - } ?: -1L - } catch (e: Exception) { - -1L - } - val segs = uri.pathSegments.flatMap { Uri.decode(it).split('/') }.filter { it.isNotEmpty() } - val roots = storageVolumes().map { it["path"] as String } - for (i in segs.indices) { - val rest = segs.drop(i).joinToString("/") - for (root in roots) { - val f = File(root, rest) - if (f.isFile && (size < 0 || f.length() == size)) return f.absolutePath - } - // 경로 조각 안에 저장소 경로가 그대로 있는 경우 (/storage/emulated/0/…) - val abs = "/$rest" - if (abs.startsWith("/storage/") && ok(abs)) return abs - } - return null - } - - /// 쓸 수 있는 저장소 (내장 · SD 카드 · USB 메모리): [{path, label, removable}] - private fun storageVolumes(): List> { - val sm = getSystemService(STORAGE_SERVICE) as android.os.storage.StorageManager - val out = mutableListOf>() - for (v in sm.storageVolumes) { - if (v.state != Environment.MEDIA_MOUNTED) continue - val dir: File? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - v.directory - } else { - try { - v.javaClass.getMethod("getPathFile").invoke(v) as File? - } catch (e: Exception) { - null - } - } - if (dir == null) continue - out.add( - mapOf( - "path" to dir.absolutePath, - "label" to (v.getDescription(this) ?: dir.name), - "removable" to v.isRemovable, - "primary" to v.isPrimary, - ) - ) - } - return out - } - - /// 웹 주소를 기기의 기본 브라우저로 - private fun openUrl(url: String): Boolean = try { - startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) - true - } catch (e: Exception) { - false - } - - /// 폴더를 파일 앱으로 연다 (내장 저장소 안의 폴더만). 열 앱이 없으면 false. - /// 앱 아이콘 바꾸기: 고른 아이콘의 activity-alias 만 켜고 나머지는 끈다 (앱은 계속 켜 둔 채로) - private fun setAppIcon(id: String): Boolean { - val ids = listOf("yellow", "black", "film_jj", "film", "blue") - if (id !in ids) return false - val pm = packageManager - for (i in ids) { - val name = "$packageName.Icon" + i.split('_').joinToString("") { it.replaceFirstChar(Char::uppercaseChar) } - val state = if (i == id) PackageManager.COMPONENT_ENABLED_STATE_ENABLED - else PackageManager.COMPONENT_ENABLED_STATE_DISABLED - val cn = android.content.ComponentName(this, name) - if (pm.getComponentEnabledSetting(cn) != state) { - pm.setComponentEnabledSetting(cn, state, PackageManager.DONT_KILL_APP) - } - } - return true - } - - /// 파일 탐색기: 파일을 다른 앱으로 연다. [choose] 면 늘 앱 고르기 창 (이 앱은 뺌), 아니면 기본 앱 (없으면 고르기 창). - /// SD 카드 · USB 파일도 넘길 수 있게 FileProvider 의 root 경로를 쓴다 (res/xml/jj_file_paths.xml). - private fun openWith(path: String, choose: Boolean): Boolean { - val f = File(path) - if (!f.isFile) return false - val uri = try { - androidx.core.content.FileProvider.getUriForFile(this, "$packageName.files", f) - } catch (e: Exception) { - return false - } - val mime = android.webkit.MimeTypeMap.getSingleton().getMimeTypeFromExtension(f.extension.lowercase()) ?: "*/*" - val view = Intent(Intent.ACTION_VIEW) - .setDataAndType(uri, mime) - .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) - fun chooser(): Intent = Intent.createChooser(view, null) - .putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, arrayOf(android.content.ComponentName(this, MainActivity::class.java))) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - return try { - startActivity(if (choose) chooser() else view) - true - } catch (e: Exception) { - try { - startActivity(chooser()) - true - } catch (e2: Exception) { - false - } - } - } - - /// 77: 파일 탐색기의 [공유] - 파일 하나는 ACTION_SEND, 여럿은 ACTION_SEND_MULTIPLE (공유 창, 이 앱은 뺌) - private fun shareFiles(paths: List): Boolean { - val uris = ArrayList() - val mimes = mutableSetOf() - for (p in paths) { - val f = File(p) - if (!f.isFile) continue - uris.add( - try { - androidx.core.content.FileProvider.getUriForFile(this, "$packageName.files", f) - } catch (e: Exception) { - return false - } - ) - mimes.add(android.webkit.MimeTypeMap.getSingleton().getMimeTypeFromExtension(f.extension.lowercase()) ?: "*/*") - } - if (uris.isEmpty()) return false - // 종류가 하나면 그것, 앞부분 (video/ 등) 만 같으면 video/*, 아니면 */* - val tops = mimes.map { it.substringBefore('/') }.toSet() - val mime = if (mimes.size == 1) mimes.first() else if (tops.size == 1 && tops.first() != "*") "${tops.first()}/*" else "*/*" - val send = if (uris.size == 1) { - Intent(Intent.ACTION_SEND).putExtra(Intent.EXTRA_STREAM, uris[0]) - } else { - Intent(Intent.ACTION_SEND_MULTIPLE).putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris) - } - send.type = mime - // 받는 앱이 모든 파일을 읽을 수 있게 (EXTRA_STREAM 만으로는 첫 것만 허락되는 앱이 있다) - val clip = android.content.ClipData.newRawUri(null, uris[0]) - for (i in 1 until uris.size) clip.addItem(android.content.ClipData.Item(uris[i])) - send.clipData = clip - send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - return try { - startActivity( - Intent.createChooser(send, null) - .putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, arrayOf(android.content.ComponentName(this, MainActivity::class.java))) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - ) - true - } catch (e: Exception) { - false - } - } - - private fun openFolder(path: String): Boolean { - // 내장 저장소 (primary) 또는 SD 카드 · USB (/storage/XXXX-XXXX → 문서 ID "XXXX-XXXX:...") - val root = Environment.getExternalStorageDirectory().path - val (volumeId, rel) = when { - path.startsWith(root) -> "primary" to path.removePrefix(root).trim('/') - path.startsWith("/storage/") -> { - val rest = path.removePrefix("/storage/") - rest.substringBefore('/') to rest.substringAfter('/', "").trim('/') - } - else -> return false - } - // 1. 삼성 "내 파일": 그 폴더로 바로 연다 - try { - startActivity( - Intent("samsung.myfiles.intent.action.LAUNCH_MY_FILES") - .setPackage("com.sec.android.app.myfiles") - .putExtra("samsung.myfiles.intent.extra.START_PATH", path) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - ) - return true - } catch (e: Exception) { - } - // 2. 시스템 "파일" 앱 (Google / AOSP). 다른 파일 관리자 (X-plore 등) 는 이 주소의 폴더로 가지 않아 앱을 정해 연다 - val uri = android.provider.DocumentsContract.buildDocumentUri( - "com.android.externalstorage.documents", "$volumeId:$rel" - ) - for (pkg in listOf("com.google.android.documentsui", "com.android.documentsui", null)) { - try { - startActivity( - Intent(Intent.ACTION_VIEW) - .setDataAndType(uri, android.provider.DocumentsContract.Document.MIME_TYPE_DIR) - .apply { if (pkg != null) setPackage(pkg) } - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION) - ) - return true - } catch (e: Exception) { - } - } - return false - } - - /// 작업 진행 알림 (포그라운드 서비스) 시작 · 갱신. 처음 한 번 알림 권한을 묻는다 (Android 13+, 거절해도 작업은 계속) - private var askedNotifications = false - - private fun keepAlive(text: String, progress: Int, icon: String) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !askedNotifications && !isDestroyed && - checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED - ) { - askedNotifications = true - requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 2) - } - if (KeepAliveService.running) { - KeepAliveService.update(applicationContext, text, progress, icon) - return - } - val i = Intent(applicationContext, KeepAliveService::class.java) - .putExtra(KeepAliveService.EXTRA_TEXT, text) - .putExtra(KeepAliveService.EXTRA_PROGRESS, progress) - .putExtra(KeepAliveService.EXTRA_ICON, icon) - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) applicationContext.startForegroundService(i) - else applicationContext.startService(i) - } catch (e: Exception) { - // 백그라운드에서는 새로 시작할 수 없다 (Android 12+). 이미 떠 있으면 다음 갱신 때 바뀐다 - android.util.Log.w("jj_mkvmaker", "keepAlive", e) - } - } - - /// 오래 걸리는 일 (처음 켤 때 실행 파일 풀기 · yt-dlp 업데이트) 은 메인 스레드 밖에서 - /// X-plore 비교 5-2: 런처에 보이는 앱 (이름 · 패키지 · 판 · 크기 · 설치일 · APK 경로 · 나눈 APK 수 · 시스템 앱인지) - private fun launcherApps(): List> { - val pm = packageManager - val seen = HashSet() - val out = ArrayList>() - for (ri in pm.queryIntentActivities(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER), 0)) { - val pkg = ri.activityInfo.packageName - if (!seen.add(pkg)) continue - try { - @Suppress("DEPRECATION") - val pi = pm.getPackageInfo(pkg, 0) - val ai = pi.applicationInfo ?: continue - val dirs = listOfNotNull(ai.sourceDir) + (ai.splitSourceDirs?.toList() ?: emptyList()) - out.add(mapOf( - "package" to pkg, - "label" to ri.loadLabel(pm).toString(), - "version" to (pi.versionName ?: ""), - "installed" to pi.firstInstallTime, - "updated" to pi.lastUpdateTime, - "size" to dirs.sumOf { java.io.File(it).length() }, - "apk" to ai.sourceDir, - "splits" to (ai.splitSourceDirs?.size ?: 0), - "system" to ((ai.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0), - )) - } catch (e: Exception) { - } - } - return out - } - - /// 앱 아이콘 (PNG, [size] 픽셀) - private fun appIcon(pkg: String, size: Int): ByteArray? = try { - val d = packageManager.getApplicationIcon(pkg) - val bmp = android.graphics.Bitmap.createBitmap(size, size, android.graphics.Bitmap.Config.ARGB_8888) - d.setBounds(0, 0, size, size) - d.draw(android.graphics.Canvas(bmp)) - val bos = java.io.ByteArrayOutputStream() - bmp.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, bos) - bos.toByteArray() - } catch (e: Exception) { - null - } - - /// Shizuku 허락 창 - 답 (허락했는지) 을 돌려준다 - private var shizukuPending: MethodChannel.Result? = null - private val shizukuListener = rikka.shizuku.Shizuku.OnRequestPermissionResultListener { code, grant -> - if (code == 5202) { - shizukuPending?.success(grant == PackageManager.PERMISSION_GRANTED) - shizukuPending = null - } - } - - private fun shizukuRequest(result: MethodChannel.Result) { - try { - if (!rikka.shizuku.Shizuku.pingBinder()) return result.success(false) - if (rikka.shizuku.Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) return result.success(true) - shizukuPending?.success(false) - shizukuPending = result - rikka.shizuku.Shizuku.removeRequestPermissionResultListener(shizukuListener) - rikka.shizuku.Shizuku.addRequestPermissionResultListener(shizukuListener) - rikka.shizuku.Shizuku.requestPermission(5202) - } catch (e: Throwable) { - shizukuPending = null - result.success(false) - } - } - - private fun inBackground(result: MethodChannel.Result, work: () -> Any?) { - Thread { - try { - val value = work() - runOnUiThread { result.success(value) } - } catch (e: Throwable) { - android.util.Log.e("jj_mkvmaker", "download tools", e) - val cause = generateSequence(e) { it.cause }.last() - runOnUiThread { result.error("TOOLS", "${e.javaClass.simpleName}: ${cause.message ?: cause}", null) } - } - }.start() - } - - /// youtubedl-android 가 앱에 넣은 Python · yt-dlp · ffmpeg · aria2c 를 풀고, 직접 실행할 경로 · 환경 변수를 돌려준다. - /// 경로 규칙은 youtubedl-android 0.18.1 의 YoutubeDL.init / FFmpeg.init / Aria2c.init 과 같다. - private fun downloadToolsInit(): Map { - YoutubeDL.init(applicationContext) - FFmpeg.init(applicationContext) - Aria2c.init(applicationContext) - val bin = applicationInfo.nativeLibraryDir - val base = File(noBackupFilesDir, YoutubeDL.baseName) - val packages = File(base, "packages") - val python = File(packages, "python").absolutePath - val ffmpeg = File(packages, "ffmpeg").absolutePath - val aria2c = File(packages, "aria2c").absolutePath - val cert = "$python/usr/etc/tls/cert.pem" - return mapOf( - "python" to "$bin/libpython.so", - "ytdlp" to File(File(base, YoutubeDL.ytdlpDirName), YoutubeDL.ytdlpBin).absolutePath, - "ffmpeg" to "$bin/libffmpeg.so", - "quickjs" to "$bin/libqjs.so", - "aria2c" to "$bin/libaria2c.so", - "cert" to cert, - "version" to (YoutubeDL.versionName(applicationContext) ?: ""), - "env" to mapOf( - "LD_LIBRARY_PATH" to "$python/usr/lib:$ffmpeg/usr/lib:$aria2c/usr/lib", - "SSL_CERT_FILE" to cert, - "PATH" to (System.getenv("PATH") ?: "") + ":" + bin, - "PYTHONHOME" to "$python/usr", - "HOME" to "$python/usr", - "TMPDIR" to cacheDir.absolutePath, - ), - ) - } - - /// Tor 화면 전용 웹 뷰 프로필을 지운다 (없으면 true). 쓰는 웹 뷰가 남아 있으면 쿠키 · 저장소만 지우고 false - private fun clearWebProfile(name: String): Boolean { - if (!WebViewFeature.isFeatureSupported(WebViewFeature.MULTI_PROFILE)) return true - val store = ProfileStore.getInstance() - if (store.getProfile(name) == null) return true - return try { - store.deleteProfile(name) - } catch (e: IllegalStateException) { - try { - val profile = store.getProfile(name) - profile?.cookieManager?.removeAllCookies(null) - profile?.webStorage?.deleteAllData() - } catch (_: Exception) { - } - false - } - } - - private fun hasAllFilesAccess(): Boolean = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - Environment.isExternalStorageManager() - } else { - checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED - } - - private fun requestAllFilesAccess() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - try { - startActivity(Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, Uri.parse("package:$packageName"))) - } catch (e: Exception) { - startActivity(Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)) - } - } else { - requestPermissions( - arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE), 1 - ) - } - } - - /// IPv4 주소 → 망 종류 ("wifi" · "ethernet" · "cellular" · "vpn" · "other"). 읽지 못하면 null (Dart 가 이름 방식으로) - @Suppress("DEPRECATION") - private fun netKinds(): Map? = try { - val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager - val out = HashMap() - for (n in cm.allNetworks) { - val caps = cm.getNetworkCapabilities(n) ?: continue - val lp = cm.getLinkProperties(n) ?: continue - val kind = when { - caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_VPN) -> "vpn" - caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_WIFI) -> "wifi" - caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet" - caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular" - else -> "other" - } - for (la in lp.linkAddresses) { - val a = la.address - if (a is java.net.Inet4Address) a.hostAddress?.let { out[it] = kind } - } - } - out - } catch (e: Exception) { - null - } } diff --git a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/OpenedFiles.kt b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/OpenedFiles.kt new file mode 100644 index 0000000..167ce42 --- /dev/null +++ b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/OpenedFiles.kt @@ -0,0 +1,106 @@ +package com.jj.jj_mkvmaker + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Environment +import java.io.File + +/// 앱이 켜질 때 받은 것 (Dart 가 준비되면 "takeOpenedFiles" · "takeShowJobs" 로 가져간다) +class LaunchInbox { + /// 다른 앱에서 연 동영상 + var openFiles: List> = emptyList() + + /// 작업 알림으로 열렸는지 + var showJobs = false +} + +/// 연결 프로그램 (VIEW) · 공유 (SEND · SEND_MULTIPLE) 로 받은 파일을 읽는 모듈. +/// 약속: +/// - 입력: 받은 [Intent] (null 이면 없음), 쓸 수 있는 저장소 목록 ([storageVolumes] - 경로 찾기에 씀). +/// - 출력: [{path (실제 경로, 모르면 null), uri, name}] 목록. 그 밖의 action 이면 빈 목록. +/// - 오류: 던지지 않는다. 경로 · 이름을 읽지 못하면 null. +class OpenedFiles(private val context: Context) { + fun of(intent: Intent?): List> { + if (intent == null) return emptyList() + val uris = mutableListOf() + when (intent.action) { + Intent.ACTION_VIEW -> intent.data?.let { uris.add(it) } + Intent.ACTION_SEND -> intentStream(intent)?.let { uris.add(it) } + Intent.ACTION_SEND_MULTIPLE -> intentStreams(intent)?.let { uris.addAll(it) } + } + return uris.map { mapOf("path" to realPath(it), "uri" to it.toString(), "name" to displayName(it)) } + } + + @Suppress("DEPRECATION") + private fun intentStream(i: Intent): Uri? = + if (Build.VERSION.SDK_INT >= 33) i.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) + else i.getParcelableExtra(Intent.EXTRA_STREAM) + + @Suppress("DEPRECATION") + private fun intentStreams(i: Intent): List? = + if (Build.VERSION.SDK_INT >= 33) i.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java) + else i.getParcelableArrayListExtra(Intent.EXTRA_STREAM) + + private fun displayName(uri: Uri): String? = try { + context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use { + if (it.moveToFirst()) it.getString(0) else null + } + } catch (e: Exception) { + null + } ?: uri.lastPathSegment?.substringAfterLast('/') + + /// content:// → 실제 파일 경로 ("모든 파일에 대한 접근" 이 있으면 그 경로로 읽고 옆에 MKV 를 만들 수 있다). 모르면 null. + private fun realPath(uri: Uri): String? { + fun ok(path: String?) = path != null && File(path).isFile + if (uri.scheme == "file") return uri.path?.takeIf { ok(it) } + if (uri.scheme != "content") return null + // 1. 저장소 문서 (primary:Download/a.mp4 · XXXX-XXXX:Movies/a.mp4) + try { + if (android.provider.DocumentsContract.isDocumentUri(context, uri) && + uri.authority == "com.android.externalstorage.documents" + ) { + val id = android.provider.DocumentsContract.getDocumentId(uri) + val vol = id.substringBefore(':') + val rel = id.substringAfter(':', "") + val base = if (vol == "primary") Environment.getExternalStorageDirectory().path else "/storage/$vol" + val path = "$base/$rel" + if (ok(path)) return path + } + } catch (e: Exception) { + } + // 2. 미디어 저장소 등이 알려 주는 경로 (_data) + try { + context.contentResolver.query(uri, arrayOf("_data"), null, null, null)?.use { + if (it.moveToFirst()) { + val path = it.getString(0) + if (ok(path)) return path + } + } + } catch (e: Exception) { + } + // 3. 주소 안의 경로 조각을 저장소 맨 위에 붙여 본다 (파일 앱마다 주소 모양이 달라서) + // 예: content://…/device_storage/0/Download/a.mp4 → /storage/emulated/0/Download/a.mp4 (크기가 같을 때만) + val size = try { + context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.SIZE), null, null, null)?.use { + if (it.moveToFirst() && !it.isNull(0)) it.getLong(0) else -1L + } ?: -1L + } catch (e: Exception) { + -1L + } + val segs = uri.pathSegments.flatMap { Uri.decode(it).split('/') }.filter { it.isNotEmpty() } + val roots = context.storageVolumes().map { it["path"] as String } + for (i in segs.indices) { + val rest = segs.drop(i).joinToString("/") + for (root in roots) { + val f = File(root, rest) + if (f.isFile && (size < 0 || f.length() == size)) return f.absolutePath + } + // 경로 조각 안에 저장소 경로가 그대로 있는 경우 (/storage/emulated/0/…) + val abs = "/$rest" + if (abs.startsWith("/storage/") && ok(abs)) return abs + } + return null + } +} diff --git a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/StorageChannel.kt b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/StorageChannel.kt new file mode 100644 index 0000000..2410d92 --- /dev/null +++ b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/StorageChannel.kt @@ -0,0 +1,235 @@ +package com.jj.jj_mkvmaker + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.media.MediaScannerConnection +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.Settings +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.io.File + +/// 저장소 · 파일 (전체 접근 · 저장소 목록 · 남은 공간 · 미디어 다시 읽기 · 주소 · 폴더 열기 · 다른 앱으로 열기 · 공유) 채널 처리기. +/// 약속: +/// - 입력: 메서드 hasAllFilesAccess · requestAllFilesAccess · storageRoot · nativeLibDir · storageVolumes · openUrl · openFolder · +/// mediaScan · diskSpace · openWith · shareFiles 와 그 인자 (나누기 전과 같음). +/// - 출력: 메서드마다 같은 답 (참 · 거짓 · 경로 · 목록 · [남은, 전체] 바이트). 맡지 않은 메서드면 false. +/// - 오류: 던지지 않는다. 열 앱이 없거나 읽지 못하면 false · null. +class StorageChannel(private val activity: Activity) : ChannelModule { + override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean { + with(activity) { + when (call.method) { + "hasAllFilesAccess" -> result.success(hasAllFilesAccess()) + "requestAllFilesAccess" -> { + requestAllFilesAccess() + result.success(null) + } + "storageRoot" -> result.success(Environment.getExternalStorageDirectory().path) + // 앱에 들어 있는 rsync (librsync.so) 가 풀리는 곳 + "nativeLibDir" -> result.success(applicationInfo.nativeLibraryDir) + "storageVolumes" -> result.success(storageVolumes()) + "openUrl" -> result.success(openUrl(call.argument("url") ?: "")) + "openFolder" -> result.success(openFolder(call.argument("path") ?: "")) + // X-plore 맞추기 (관리자 지시 10/20): .nomedia 를 넣고 지운 뒤 갤러리 · 음악 앱이 다시 읽게 (실패해도 조용히) + "mediaScan" -> result.success(try { + val paths = call.argument>("paths") ?: emptyList() + if (paths.isNotEmpty()) MediaScannerConnection.scanFile(applicationContext, paths.toTypedArray(), null, null) + true + } catch (e: Exception) { + false + }) + "diskSpace" -> result.success( + try { + val st = android.os.StatFs(call.argument("path") ?: "") + listOf(st.availableBytes, st.totalBytes) + } catch (e: Exception) { + null + } + ) + "openWith" -> result.success( + openWith(call.argument("path") ?: "", call.argument("choose") ?: false) + ) + "shareFiles" -> result.success(shareFiles(call.argument>("paths") ?: emptyList())) + else -> return false + } + } + return true + } + + /// 웹 주소를 기기의 기본 브라우저로 + private fun Activity.openUrl(url: String): Boolean = try { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + true + } catch (e: Exception) { + false + } + + /// 파일 탐색기: 파일을 다른 앱으로 연다. [choose] 면 늘 앱 고르기 창 (이 앱은 뺌), 아니면 기본 앱 (없으면 고르기 창). + /// SD 카드 · USB 파일도 넘길 수 있게 FileProvider 의 root 경로를 쓴다 (res/xml/jj_file_paths.xml). + private fun Activity.openWith(path: String, choose: Boolean): Boolean { + val f = File(path) + if (!f.isFile) return false + val uri = try { + androidx.core.content.FileProvider.getUriForFile(this, "$packageName.files", f) + } catch (e: Exception) { + return false + } + val mime = android.webkit.MimeTypeMap.getSingleton().getMimeTypeFromExtension(f.extension.lowercase()) ?: "*/*" + val view = Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, mime) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) + fun chooser(): Intent = Intent.createChooser(view, null) + .putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, arrayOf(android.content.ComponentName(this, MainActivity::class.java))) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + return try { + startActivity(if (choose) chooser() else view) + true + } catch (e: Exception) { + try { + startActivity(chooser()) + true + } catch (e2: Exception) { + false + } + } + } + + /// 77: 파일 탐색기의 [공유] - 파일 하나는 ACTION_SEND, 여럿은 ACTION_SEND_MULTIPLE (공유 창, 이 앱은 뺌) + private fun Activity.shareFiles(paths: List): Boolean { + val uris = ArrayList() + val mimes = mutableSetOf() + for (p in paths) { + val f = File(p) + if (!f.isFile) continue + uris.add( + try { + androidx.core.content.FileProvider.getUriForFile(this, "$packageName.files", f) + } catch (e: Exception) { + return false + } + ) + mimes.add(android.webkit.MimeTypeMap.getSingleton().getMimeTypeFromExtension(f.extension.lowercase()) ?: "*/*") + } + if (uris.isEmpty()) return false + // 종류가 하나면 그것, 앞부분 (video/ 등) 만 같으면 video/*, 아니면 */* + val tops = mimes.map { it.substringBefore('/') }.toSet() + val mime = if (mimes.size == 1) mimes.first() else if (tops.size == 1 && tops.first() != "*") "${tops.first()}/*" else "*/*" + val send = if (uris.size == 1) { + Intent(Intent.ACTION_SEND).putExtra(Intent.EXTRA_STREAM, uris[0]) + } else { + Intent(Intent.ACTION_SEND_MULTIPLE).putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris) + } + send.type = mime + // 받는 앱이 모든 파일을 읽을 수 있게 (EXTRA_STREAM 만으로는 첫 것만 허락되는 앱이 있다) + val clip = android.content.ClipData.newRawUri(null, uris[0]) + for (i in 1 until uris.size) clip.addItem(android.content.ClipData.Item(uris[i])) + send.clipData = clip + send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + return try { + startActivity( + Intent.createChooser(send, null) + .putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, arrayOf(android.content.ComponentName(this, MainActivity::class.java))) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + true + } catch (e: Exception) { + false + } + } + + /// 폴더를 파일 앱으로 연다 (내장 저장소 안의 폴더만). 열 앱이 없으면 false. + private fun Activity.openFolder(path: String): Boolean { + // 내장 저장소 (primary) 또는 SD 카드 · USB (/storage/XXXX-XXXX → 문서 ID "XXXX-XXXX:...") + val root = Environment.getExternalStorageDirectory().path + val (volumeId, rel) = when { + path.startsWith(root) -> "primary" to path.removePrefix(root).trim('/') + path.startsWith("/storage/") -> { + val rest = path.removePrefix("/storage/") + rest.substringBefore('/') to rest.substringAfter('/', "").trim('/') + } + else -> return false + } + // 1. 삼성 "내 파일": 그 폴더로 바로 연다 + try { + startActivity( + Intent("samsung.myfiles.intent.action.LAUNCH_MY_FILES") + .setPackage("com.sec.android.app.myfiles") + .putExtra("samsung.myfiles.intent.extra.START_PATH", path) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + return true + } catch (e: Exception) { + } + // 2. 시스템 "파일" 앱 (Google / AOSP). 다른 파일 관리자 (X-plore 등) 는 이 주소의 폴더로 가지 않아 앱을 정해 연다 + val uri = android.provider.DocumentsContract.buildDocumentUri( + "com.android.externalstorage.documents", "$volumeId:$rel" + ) + for (pkg in listOf("com.google.android.documentsui", "com.android.documentsui", null)) { + try { + startActivity( + Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, android.provider.DocumentsContract.Document.MIME_TYPE_DIR) + .apply { if (pkg != null) setPackage(pkg) } + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION) + ) + return true + } catch (e: Exception) { + } + } + return false + } + + private fun Activity.hasAllFilesAccess(): Boolean = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Environment.isExternalStorageManager() + } else { + checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED + } + + private fun Activity.requestAllFilesAccess() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + try { + startActivity(Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, Uri.parse("package:$packageName"))) + } catch (e: Exception) { + startActivity(Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)) + } + } else { + requestPermissions( + arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE), 1 + ) + } + } +} + +/// 쓸 수 있는 저장소 (내장 · SD 카드 · USB 메모리): [{path, label, removable}] +fun Context.storageVolumes(): List> { + val sm = getSystemService(Context.STORAGE_SERVICE) as android.os.storage.StorageManager + val out = mutableListOf>() + for (v in sm.storageVolumes) { + if (v.state != Environment.MEDIA_MOUNTED) continue + val dir: File? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + v.directory + } else { + try { + v.javaClass.getMethod("getPathFile").invoke(v) as File? + } catch (e: Exception) { + null + } + } + if (dir == null) continue + out.add( + mapOf( + "path" to dir.absolutePath, + "label" to (v.getDescription(this) ?: dir.name), + "removable" to v.isRemovable, + "primary" to v.isPrimary, + ) + ) + } + return out +} diff --git a/android/app/src/main/kotlin/com/jj/jj_mkvmaker/SystemChannel.kt b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/SystemChannel.kt new file mode 100644 index 0000000..431e6d4 --- /dev/null +++ b/android/app/src/main/kotlin/com/jj/jj_mkvmaker/SystemChannel.kt @@ -0,0 +1,223 @@ +package com.jj.jj_mkvmaker + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.provider.Settings +import androidx.webkit.ProfileStore +import androidx.webkit.WebViewFeature +import com.yausername.aria2c.Aria2c +import com.yausername.ffmpeg.FFmpeg +import com.yausername.youtubedl_android.YoutubeDL +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.io.File + +/// 기기 · 시스템 (Tor 웹 뷰 · Shizuku 시스템 폴더 · 앱 관리자 · 망 종류 · 다운로드 프로그램) 채널 처리기. +/// 약속: +/// - 입력: 메서드 torWebSupport · torProfileClear · shizukuStatus · shizukuRequest · shizukuList · shizukuStat · shizukuCopy · +/// launcherApps · appIcon · appDetails · uninstallApp · netKinds · activeMetered · downloadToolsInit · downloadToolsUpdate +/// 와 그 인자 (나누기 전과 같음). +/// - 출력: 메서드마다 같은 답 (지도 · 목록 · PNG 바이트 · 참 · 거짓). 오래 걸리는 것은 [inBackground] 로. 맡지 않은 메서드면 false. +/// - 오류: 뒤에서 하는 일이 실패하면 "TOOLS", 그 밖에는 false · null (Shizuku 허락 실패는 false). +class SystemChannel(private val activity: Activity) : ChannelModule { + override fun handle(call: MethodCall, result: MethodChannel.Result): Boolean { + with(activity) { + when (call.method) { + // Tor 화면: 이 기기 웹 뷰가 프록시 걸기 · 따로 프로필 · 페이지보다 먼저 스크립트를 지원하는지 (없으면 화면을 열지 않음) + "torWebSupport" -> result.success(mapOf( + "proxyOverride" to WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE), + "multiProfile" to WebViewFeature.isFeatureSupported(WebViewFeature.MULTI_PROFILE), + "documentStartScript" to WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT), + )) + // Tor 화면 프로필 (쿠키 · 저장소 · 캐시) 지우기. 웹 뷰가 아직 쓰고 있으면 false (나중에 다시) + "torProfileClear" -> result.success(clearWebProfile(call.argument("name") ?: "jj_tor")) + // X-plore 비교 5-2 시스템 폴더 보기 (Shizuku, 읽기 전용 - ShizukuBridge 의 고정 명령 셋뿐) + "shizukuStatus" -> result.success(ShizukuBridge.status()) + "shizukuRequest" -> shizukuRequest(result) + "shizukuList" -> inBackground(result) { ShizukuBridge.list(call.argument("path") ?: "") } + "shizukuStat" -> inBackground(result) { ShizukuBridge.stat(call.argument("path") ?: "") } + "shizukuCopy" -> inBackground(result) { + ShizukuBridge.copyTo(call.argument("path") ?: "", call.argument("dest") ?: "") + } + // X-plore 비교 5-2 앱 관리자 (사용자 결정 10/21, 관리자 판단: 모든 앱 목록 권한 없이 런처에 보이는 앱만) + "launcherApps" -> inBackground(result) { launcherApps() } + "appIcon" -> inBackground(result) { appIcon(call.argument("package") ?: "", call.argument("size") ?: 96) } + "appDetails" -> result.success(try { + startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:" + (call.argument("package") ?: ""))) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + true + } catch (e: Exception) { + false + }) + // 지우기는 시스템의 지우기 창 (사용자가 거기서 확인) + "uninstallApp" -> result.success(try { + startActivity(Intent(Intent.ACTION_DELETE, Uri.parse("package:" + (call.argument("package") ?: ""))) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + true + } catch (e: Exception) { + false + }) + // 28 APK 점검 · 관리자 판단 (10/19): 주소마다 망 종류 - Wi-Fi 공유 · SMB 찾기가 Wi-Fi · 유선만 쓰게 (모바일 데이터 · VPN 은 뺀다) + "netKinds" -> result.success(netKinds()) + // X-plore 비교 5-1 ② (관리자 지시 10/21): 지금 쓰는 망이 데이터 요금제인지 (모바일 데이터 · 요금제로 정한 Wi-Fi). + // VPN (Tailscale 등) 은 그 아래 망을 따른다 - 집 Wi-Fi 위의 VPN 은 요금제 아님, 모바일 데이터 위의 VPN 은 요금제 + "activeMetered" -> result.success(try { + (getSystemService(Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager).isActiveNetworkMetered + } catch (e: Exception) { + true + }) + "downloadToolsInit" -> inBackground(result) { downloadToolsInit() } + "downloadToolsUpdate" -> inBackground(result) { + YoutubeDL.updateYoutubeDL(applicationContext)?.name ?: "" + } + else -> return false + } + } + return true + } + + /// X-plore 비교 5-2: 런처에 보이는 앱 (이름 · 패키지 · 판 · 크기 · 설치일 · APK 경로 · 나눈 APK 수 · 시스템 앱인지) + private fun Activity.launcherApps(): List> { + val pm = packageManager + val seen = HashSet() + val out = ArrayList>() + for (ri in pm.queryIntentActivities(Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER), 0)) { + val pkg = ri.activityInfo.packageName + if (!seen.add(pkg)) continue + try { + @Suppress("DEPRECATION") + val pi = pm.getPackageInfo(pkg, 0) + val ai = pi.applicationInfo ?: continue + val dirs = listOfNotNull(ai.sourceDir) + (ai.splitSourceDirs?.toList() ?: emptyList()) + out.add(mapOf( + "package" to pkg, + "label" to ri.loadLabel(pm).toString(), + "version" to (pi.versionName ?: ""), + "installed" to pi.firstInstallTime, + "updated" to pi.lastUpdateTime, + "size" to dirs.sumOf { java.io.File(it).length() }, + "apk" to ai.sourceDir, + "splits" to (ai.splitSourceDirs?.size ?: 0), + "system" to ((ai.flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0), + )) + } catch (e: Exception) { + } + } + return out + } + + /// 앱 아이콘 (PNG, [size] 픽셀) + private fun Activity.appIcon(pkg: String, size: Int): ByteArray? = try { + val d = packageManager.getApplicationIcon(pkg) + val bmp = android.graphics.Bitmap.createBitmap(size, size, android.graphics.Bitmap.Config.ARGB_8888) + d.setBounds(0, 0, size, size) + d.draw(android.graphics.Canvas(bmp)) + val bos = java.io.ByteArrayOutputStream() + bmp.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, bos) + bos.toByteArray() + } catch (e: Exception) { + null + } + + /// Shizuku 허락 창 - 답 (허락했는지) 을 돌려준다 + private var shizukuPending: MethodChannel.Result? = null + private val shizukuListener = rikka.shizuku.Shizuku.OnRequestPermissionResultListener { code, grant -> + if (code == 5202) { + shizukuPending?.success(grant == PackageManager.PERMISSION_GRANTED) + shizukuPending = null + } + } + + private fun Activity.shizukuRequest(result: MethodChannel.Result) { + try { + if (!rikka.shizuku.Shizuku.pingBinder()) return result.success(false) + if (rikka.shizuku.Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) return result.success(true) + shizukuPending?.success(false) + shizukuPending = result + rikka.shizuku.Shizuku.removeRequestPermissionResultListener(shizukuListener) + rikka.shizuku.Shizuku.addRequestPermissionResultListener(shizukuListener) + rikka.shizuku.Shizuku.requestPermission(5202) + } catch (e: Throwable) { + shizukuPending = null + result.success(false) + } + } + + /// youtubedl-android 가 앱에 넣은 Python · yt-dlp · ffmpeg · aria2c 를 풀고, 직접 실행할 경로 · 환경 변수를 돌려준다. + /// 경로 규칙은 youtubedl-android 0.18.1 의 YoutubeDL.init / FFmpeg.init / Aria2c.init 과 같다. + private fun Activity.downloadToolsInit(): Map { + YoutubeDL.init(applicationContext) + FFmpeg.init(applicationContext) + Aria2c.init(applicationContext) + val bin = applicationInfo.nativeLibraryDir + val base = File(noBackupFilesDir, YoutubeDL.baseName) + val packages = File(base, "packages") + val python = File(packages, "python").absolutePath + val ffmpeg = File(packages, "ffmpeg").absolutePath + val aria2c = File(packages, "aria2c").absolutePath + val cert = "$python/usr/etc/tls/cert.pem" + return mapOf( + "python" to "$bin/libpython.so", + "ytdlp" to File(File(base, YoutubeDL.ytdlpDirName), YoutubeDL.ytdlpBin).absolutePath, + "ffmpeg" to "$bin/libffmpeg.so", + "quickjs" to "$bin/libqjs.so", + "aria2c" to "$bin/libaria2c.so", + "cert" to cert, + "version" to (YoutubeDL.versionName(applicationContext) ?: ""), + "env" to mapOf( + "LD_LIBRARY_PATH" to "$python/usr/lib:$ffmpeg/usr/lib:$aria2c/usr/lib", + "SSL_CERT_FILE" to cert, + "PATH" to (System.getenv("PATH") ?: "") + ":" + bin, + "PYTHONHOME" to "$python/usr", + "HOME" to "$python/usr", + "TMPDIR" to cacheDir.absolutePath, + ), + ) + } + + /// Tor 화면 전용 웹 뷰 프로필을 지운다 (없으면 true). 쓰는 웹 뷰가 남아 있으면 쿠키 · 저장소만 지우고 false + private fun Activity.clearWebProfile(name: String): Boolean { + if (!WebViewFeature.isFeatureSupported(WebViewFeature.MULTI_PROFILE)) return true + val store = ProfileStore.getInstance() + if (store.getProfile(name) == null) return true + return try { + store.deleteProfile(name) + } catch (e: IllegalStateException) { + try { + val profile = store.getProfile(name) + profile?.cookieManager?.removeAllCookies(null) + profile?.webStorage?.deleteAllData() + } catch (_: Exception) { + } + false + } + } + + /// IPv4 주소 → 망 종류 ("wifi" · "ethernet" · "cellular" · "vpn" · "other"). 읽지 못하면 null (Dart 가 이름 방식으로) + @Suppress("DEPRECATION") + private fun Activity.netKinds(): Map? = try { + val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager + val out = HashMap() + for (n in cm.allNetworks) { + val caps = cm.getNetworkCapabilities(n) ?: continue + val lp = cm.getLinkProperties(n) ?: continue + val kind = when { + caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_VPN) -> "vpn" + caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_WIFI) -> "wifi" + caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet" + caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular" + else -> "other" + } + for (la in lp.linkAddresses) { + val a = la.address + if (a is java.net.Inet4Address) a.hostAddress?.let { out[it] = kind } + } + } + out + } catch (e: Exception) { + null + } +}