/// 약속: Android 시작 (창 하나 - 트레이 · 단축키 · 탐색기 연결 · 클립보드 감시는 없다). 공용 시작 단계 ([startCore]) 를 부르고 /// 자동 백업 판별 · 공용 폴더 · 화면 방향 · 권한 · 다른 앱에서 받은 파일 · 백그라운드 실행을 잇는다. library; import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:path/path.dart' as p; import '../app/app_controller.dart'; import '../app/model_backup.dart'; import '../app/install_marker.dart'; import '../app/net_guard.dart'; import '../app/tor_service.dart'; import '../ui/settings_problem.dart'; import '../ui/migration_notice.dart'; import '../ui/master_prompt.dart'; import '../ui/secret_issue.dart'; import '../app/settings.dart'; import '../platform/android/android_download_tools.dart'; import '../platform/android/android_keep_alive.dart'; import '../platform/android/android_shell.dart' show applyScreenOrientation; import '../platform/android/android_storage.dart'; import '../ui/all_files_guide.dart'; import '../services/platform_services.dart'; import '../ui/app_actions.dart'; import '../ui/downloads_page.dart'; import '../ui/monitor_page.dart' show askLiveSyncStart; import '../ui/update_dialog.dart'; import '../ui/version_restore.dart'; import '../ui/work_panel.dart'; import '../l10n/tr.dart'; import '../platform/android/android_updater.dart'; import '../ui/app_root.dart'; import 'startup_helpers.dart'; import 'app_start.dart'; /// Android: 창 하나. 트레이 · 단축키 · 탐색기 연결 · 다운로드 (yt-dlp · aria2) · 업데이트는 없다. /// 화면 방향은 기본이 자동 (기기를 돌리는 대로). 환경 설정 > 화면 방향에서 가로 · 세로 고정. Future runAndroid(String dataDir) async { HttpOverrides.global = JjHttpOverrides(); // DNS 강제: HttpClient 를 만들기 전에 (main 과 같은 까닭) final services = PlatformServices.create(); final logs = Directory(p.join(dataDir, 'Logs'))..createSync(recursive: true); final controller = AppController(services, settingsStore: SettingsStore())..logFile = p.join(logs.path, 'app.log'); controller.note(trf('── 시작 {0} (Android) ──', [appTitle])); // 버전별 설정 보관: 앱을 지웠다 다시 설치해도 남도록 공용 Download/JJ_MKVMaker 에도 (예전 버전으로 되돌릴 때) final freshInstall = !File(p.join(dataDir, 'settings.json')).existsSync(); // 115: 다시 설치한 뒤 처음 켰는데 설정이 있으면 Android 자동 백업이 되살린 것 (표시 파일은 백업에서 뺐다) final marker = InstallMarker(dataDir); var autoBackup = false; try { final info = await PackageInfo.fromPlatform(); autoBackup = restoredByAutoBackup( settingsExisted: !freshInstall, markerExists: marker.exists, installTime: info.installTime, updateTime: info.updateTime); } catch (_) {} marker.write(); if (autoBackup) controller.note(tr('Android 자동 백업에서 되살린 설정으로 시작합니다')); String? sharedSnapshots; try { final root = await const MethodChannel('jj_mkvmaker/android').invokeMethod('storageRoot'); // l10n-skip: 실제 폴더 이름 (예전 보관을 찾아야 하므로 바꾸지 않음) if (root != null) sharedSnapshots = p.join(root, 'Download', 'JJ_MKVMaker', '설정 보관'); if (root != null) ModelBackup.sharedRoot = p.join(root, 'Download', 'JJ_MKVMaker'); } catch (_) {} // 공용 시작 단계 (Windows 와 같은 차례 - app_start). 화면 언어 다음에 화면 방향 · 앱 아이콘 final (:live, :bookmarks) = await startCore(controller, dataDir, sharedSnapshots: sharedSnapshots, afterLanguage: () async { await applyScreenOrientation(controller.settings.display.orientation); unawaited(services.shell.setAppIcon(controller.settings.display.appIcon)); }); final navigatorKey = GlobalKey(); final messengerKey = GlobalKey(); AndroidStorageService.navigatorKey = navigatorKey; attachMasterPrompt(navigatorKey); // 다운로드 (앱 안 브라우저의 [다운로드] · 다운로드 목록): 앱에 넣은 yt-dlp · aria2c. 클립보드 감시는 하지 않는다. AndroidDownloadTools.log = controller.note; final downloads = createDownloads(controller, messengerKey: messengerKey, navigatorKey: navigatorKey); watchDnsFailure(messengerKey, controller); // 진행 중인 일이 있으면 화면에서 내려가도 계속 (알림에 진행 상황) AndroidKeepAlive(controller, downloads); // 170: 업데이트 설치의 첫 시도 실패 원문을 작업 기록에 final updater = services.updater; if (updater is AndroidUpdater) updater.log = controller.note; // 백그라운드로 실행: 화면 (Activity) 을 닫아도 엔진은 살아 있다가 다시 열면 새 화면에 붙는다 → 화면 방향을 다시 적용 // (웹뷰는 텍스처로 그려 새 화면에 붙어도 그대로 보인다: browser_page _webSettings) // 116: 권한 ("모든 파일에 대한 접근") 이 없어 분석에 실패한 동영상은 허용하고 돌아오면 저절로 다시 분석 controller.fileAccess = AndroidAccess.hasAllFiles; var hadAccess = await AndroidAccess.hasAllFiles(); if (hadAccess) await allFilesGuideDue(controller, hasAccess: true); AppLifecycleListener(onResume: () async { await applyScreenOrientation(controller.settings.display.orientation); final now = await AndroidAccess.hasAllFiles(); if (now && !hadAccess) await controller.reanalyzeFailed(); if (now) await allFilesGuideDue(controller, hasAccess: true); hadAccess = now; }); // 동영상 목록 기억 (앱을 껐다 켜도 그대로, 없어진 파일은 뺀다) - Windows 와 같은 파일 unawaited(controller.shareVideoList(p.join(dataDir, 'videos.json'))); // 연결 프로그램 · 공유로 받은 동영상: 앱이 켜져 있을 때 (MainActivity.onNewIntent) const android = MethodChannel('jj_mkvmaker/android'); android.setMethodCallHandler((call) async { if (call.method == 'showJobs') { // 작업 알림을 눌렀다: 작업 현황 화면으로 final nav = navigatorKey.currentState; if (nav != null) unawaited(WorkStatusPage.open(nav, controller, downloads)); return null; } if (call.method == 'openFiles') { await openFromOtherApp(navigatorKey, controller, (call.arguments as List).cast>()); } return null; }); WidgetsBinding.instance.addPostFrameCallback((_) async { // 연결 프로그램 · 공유로 앱을 켠 경우: 그 동영상부터 try { if (await android.invokeMethod('takeShowJobs') ?? false) { final nav = navigatorKey.currentState; if (nav != null) unawaited(WorkStatusPage.open(nav, controller, downloads)); } final opened = await android.invokeMethod>('takeOpenedFiles') ?? const []; if (opened.isNotEmpty) { unawaited(openFromOtherApp(navigatorKey, controller, opened.cast>())); return; } } catch (_) {} // 동영상을 읽고 옆에 MKV 를 만들려면 저장소 전체 접근이 필요하다. // 39: 켤 때마다 띄우지 않고 한 번만. 그 뒤로는 파일 탐색기 위의 안내 · 동영상 항목의 안내가 알려 준다 // 116: 다른 안내 · 분석 오류보다 먼저 (허용하고 돌아오면 실패한 분석을 다시 한다 - onResume) final ctxA = navigatorKey.currentContext; if (ctxA != null && ctxA.mounted && await allFilesGuideDue(controller, hasAccess: hadAccess) && ctxA.mounted) { await showAllFilesGuide(ctxA, AndroidAccess.request, hasAccess: AndroidAccess.hasAllFiles); } // 124: "처음 시작 시" 면 마스터 비밀번호 final ctxM = navigatorKey.currentContext; if (ctxM != null && ctxM.mounted) await askMasterAtStartup(ctxM, controller); final home = AppNavButtons.homeId(controller.settings); final nav0 = navigatorKey.currentState; if (home != 'mkv' && nav0 != null) { unawaited(AppNavButtons.openPage(nav0, home, c: controller, downloads: downloads, bookmarks: bookmarks)); } // 다시 켤 때 실시간 동기화를 골라서 시작 final ctx0 = navigatorKey.currentContext; if (controller.settings.copy.liveSyncOnStart == 'ask' && ctx0 != null && ctx0.mounted) await askLiveSyncStart(ctx0, live); // 다른 버전을 쓰다가 돌아왔거나 앱을 다시 설치했으면 보관해 둔 설정을 되살릴지 (되살리면 앱을 끝내고 다시 켜 달라고) final current = await currentAppVersion(services); final ctxV = navigatorKey.currentContext; if (ctxV != null && ctxV.mounted) await showSettingsProblem(ctxV, controller); watchSecretIssue(messengerKey, controller); if (ctxV != null && ctxV.mounted) await showMigrationNotice(ctxV, controller); if (ctxV != null && ctxV.mounted) { await checkVersionRestore(ctxV, controller, current: current, freshInstall: freshInstall, autoBackup: autoBackup, restart: () async { messengerKey.currentState?.showSnackBar(SnackBar(content: Text(tr('설정을 되살렸습니다. 앱을 끝냅니다 - 다시 켜 주세요.')))); await Future.delayed(const Duration(seconds: 2)); await services.shell.quit(); }); } // P0: 되돌리느라 공용 폴더에 옮겨 둔 받은 AI 모델 되살리기 final ctxModels = navigatorKey.currentContext; if (ctxModels != null && ctxModels.mounted) await restoreModelBackup(ctxModels, controller); // 새 버전 확인 (하루 한 번, 환경 설정에서 끌 수 있음) final ctx = navigatorKey.currentContext; if (ctx != null && ctx.mounted) unawaited(checkForUpdate(ctx, controller)); }); runApp(JjMkvMakerApp( controller: controller, downloads: downloads, bookmarks: bookmarks, navigatorKey: navigatorKey, messengerKey: messengerKey, onExit: () async { final ctx = navigatorKey.currentContext; if (ctx != null && ctx.mounted && !await confirmExit(ctx, downloads)) return; if (controller.busy && ctx != null && ctx.mounted) { final ok = await showDialog( context: ctx, builder: (c) => AlertDialog( title: Text(tr('종료')), content: Text(trf('진행 중인 작업이 있습니다: {0}\n중지하고 끝낼까요?', [controller.currentJob ?? ''])), actions: [ TextButton(onPressed: () => Navigator.pop(c, false), child: Text(tr('취소'))), FilledButton(onPressed: () => Navigator.pop(c, true), child: Text(tr('종료'))), ], ), ); if (ok != true) return; } controller.cancel(); await downloads.shutdown(); await TorService.instance.stop(); controller.note(tr('── 정상 종료 ──')); await services.shell.quit(); }, )); }